-```
-
-**Base Tailwind:**
-```
-relative p-4 border rounded-lg
-```
-
-**Type Colors:**
-
-| Type | Background | Border | Title Text | Body Text |
-|---|---|---|---|---|
-| **warning** | `bg-warning-50 dark:bg-warning-900/30` | `border-warning-300 dark:border-warning-800` | `text-warning-800 dark:text-warning-300` | `text-warning-700 dark:text-warning-200` |
-| **danger** | `bg-red-50 dark:bg-red-900/30` | `border-red-300 dark:border-red-800` | `text-red-800 dark:text-red-300` | `text-red-700 dark:text-red-200` |
-| **info** | `bg-blue-50 dark:bg-blue-900/30` | `border-blue-300 dark:border-blue-800` | `text-blue-800 dark:text-blue-300` | `text-blue-700 dark:text-blue-200` |
-| **success** | `bg-green-50 dark:bg-green-900/30` | `border-green-300 dark:border-green-800` | `text-green-800 dark:text-green-300` | `text-green-700 dark:text-green-200` |
-
-**Plain CSS (warning example):**
-```css
-.callout {
- position: relative;
- padding: 1rem;
- border: 1px solid;
- border-radius: 0.5rem;
-}
-
-.callout-warning {
- background: #fefce8;
- border-color: #fde047;
-}
-.dark .callout-warning {
- background: rgba(113, 63, 18, 0.3);
- border-color: #854d0e;
-}
-
-.callout-title {
- font-size: 1rem;
- font-weight: 700;
-}
-.callout-warning .callout-title { color: #854d0e; }
-.dark .callout-warning .callout-title { color: #fde047; }
-
-.callout-text {
- margin-top: 0.5rem;
- font-size: 0.875rem;
-}
-.callout-warning .callout-text { color: #a16207; }
-.dark .callout-warning .callout-text { color: #fef08a; }
-```
-
-**Icon colors per type:**
-- Warning: `text-warning-600 dark:text-warning-400` (`#ca8a04` / `#fcd452`)
-- Danger: `text-red-600 dark:text-red-400` (`#dc2626` / `#f87171`)
-- Info: `text-blue-600 dark:text-blue-400` (`#2563eb` / `#60a5fa`)
-- Success: `text-green-600 dark:text-green-400` (`#16a34a` / `#4ade80`)
-
----
-
-### 3.11 Toast / Notification
-
-**Container Tailwind:**
-```
-relative flex flex-col items-start
-shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]
-w-full transition-all duration-100 ease-out
-dark:bg-coolgray-100 bg-white
-dark:border dark:border-coolgray-200
-rounded-sm sm:max-w-xs
-```
-
-**Plain CSS:**
-```css
-.toast {
- position: relative;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- width: 100%;
- max-width: 20rem;
- background: #fff;
- border-radius: 0.125rem;
- box-shadow: 0 5px 15px -3px rgba(0, 0, 0, 0.08);
- transition: all 100ms ease-out;
-}
-.dark .toast {
- background: #181818;
- border: 1px solid #202020;
-}
-```
-
-**Icon colors per toast type:**
-
-| Type | Color | Hex |
-|---|---|---|
-| Success | `text-green-500` | `#22c55e` |
-| Info | `text-blue-500` | `#3b82f6` |
-| Warning | `text-orange-400` | `#fb923c` |
-| Danger | `text-red-500` | `#ef4444` |
-
-**Behavior**: Stacks up to 4 toasts, auto-dismisses after 4 seconds, positioned bottom-right.
-
----
-
-### 3.12 Modal
-
-**Tailwind (dialog-based):**
-```
-rounded-sm modal-box max-h-[calc(100vh-5rem)] flex flex-col
-```
-
-**Modal Input variant container:**
-```
-relative w-full lg:w-auto lg:min-w-2xl lg:max-w-4xl
-border rounded-sm drop-shadow-sm
-bg-white border-neutral-200
-dark:bg-base dark:border-coolgray-300
-flex flex-col
-```
-
-**Modal Confirmation container:**
-```
-relative w-full border rounded-sm
-min-w-full lg:min-w-[36rem] max-w-[48rem]
-max-h-[calc(100vh-2rem)]
-bg-neutral-100 border-neutral-400
-dark:bg-base dark:border-coolgray-300
-flex flex-col
-```
-
-**Plain CSS:**
-```css
-.modal-box {
- border-radius: 0.125rem;
- max-height: calc(100vh - 5rem);
- display: flex;
- flex-direction: column;
-}
-
-.modal-input {
- position: relative;
- width: 100%;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
- filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.05));
- background: #fff;
- display: flex;
- flex-direction: column;
-}
-.dark .modal-input {
- background: #101010;
- border-color: #242424;
-}
-
-/* Desktop sizing */
-@media (min-width: 1024px) {
- .modal-input {
- width: auto;
- min-width: 42rem;
- max-width: 56rem;
- }
-}
-```
-
-**Modal header:**
-```css
-.modal-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1.5rem;
- flex-shrink: 0;
-}
-.modal-header h3 {
- font-size: 1.5rem;
- font-weight: 700;
-}
-```
-
-**Close button:**
-```css
-.modal-close {
- width: 2rem;
- height: 2rem;
- border-radius: 9999px;
- color: #fff;
-}
-.modal-close:hover { background: #242424; }
-```
-
----
-
-### 3.13 Slide-Over Panel
-
-**Tailwind:**
-```
-fixed inset-y-0 right-0 flex max-w-full pl-10
-```
-
-**Inner panel:**
-```
-max-w-xl w-screen
-flex flex-col h-full py-6
-border-l shadow-lg
-bg-neutral-50 dark:bg-base
-dark:border-neutral-800 border-neutral-200
-```
-
-**Plain CSS:**
-```css
-.slide-over {
- position: fixed;
- top: 0;
- bottom: 0;
- right: 0;
- display: flex;
- max-width: 100%;
- padding-left: 2.5rem;
-}
-
-.slide-over-panel {
- max-width: 36rem;
- width: 100vw;
- display: flex;
- flex-direction: column;
- height: 100%;
- padding: 1.5rem 0;
- border-left: 1px solid #e5e5e5;
- box-shadow: -10px 0 15px -3px rgba(0, 0, 0, 0.1);
- background: #fafafa;
-}
-.dark .slide-over-panel {
- background: #101010;
- border-color: #262626;
-}
-```
-
----
-
-### 3.14 Tag
-
-**Tailwind:**
-```
-px-2 py-1 cursor-pointer text-xs font-bold text-neutral-500
-dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200
-```
-
-**Plain CSS:**
-```css
-.tag {
- padding: 0.25rem 0.5rem;
- font-size: 0.75rem;
- font-weight: 700;
- color: #737373;
- background: #f5f5f5;
- cursor: pointer;
-}
-.tag:hover { background: #e5e5e5; }
-.dark .tag { background: #181818; }
-.dark .tag:hover { background: #242424; }
-```
-
----
-
-### 3.15 Loading Spinner
-
-**Tailwind:**
-```
-w-4 h-4 text-coollabs dark:text-warning animate-spin
-```
-
-**Plain CSS + SVG:**
-```css
-.loading-spinner {
- width: 1rem;
- height: 1rem;
- color: #6b16ed;
- animation: spin 1s linear infinite;
-}
-.dark .loading-spinner { color: #fcd452; }
-
-@keyframes spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-```
-
-**SVG structure:**
-```html
-
-```
-
----
-
-### 3.16 Helper / Tooltip
-
-**Tailwind (trigger icon):**
-```
-cursor-pointer text-coollabs dark:text-warning
-```
-
-**Tailwind (popup):**
-```
-hidden absolute z-40 text-xs rounded-sm text-neutral-700 group-hover:block
-dark:border-coolgray-500 border-neutral-900 dark:bg-coolgray-400 bg-neutral-200
-dark:text-neutral-300 max-w-sm whitespace-normal break-words
-```
-
-**Plain CSS:**
-```css
-.helper-icon {
- cursor: pointer;
- color: #6b16ed;
-}
-.dark .helper-icon { color: #fcd452; }
-
-.helper-popup {
- display: none;
- position: absolute;
- z-index: 40;
- font-size: 0.75rem;
- border-radius: 0.125rem;
- color: #404040;
- background: #e5e5e5;
- max-width: 24rem;
- white-space: normal;
- word-break: break-word;
- padding: 1rem;
-}
-.dark .helper-popup {
- background: #282828;
- color: #d4d4d4;
- border: 1px solid #323232;
-}
-
-/* Show on parent hover */
-.helper:hover .helper-popup { display: block; }
-```
-
----
-
-### 3.17 Highlighted Text
-
-**Tailwind:**
-```
-inline-block font-bold text-coollabs dark:text-warning
-```
-
-**Plain CSS:**
-```css
-.text-highlight {
- display: inline-block;
- font-weight: 700;
- color: #6b16ed;
-}
-.dark .text-highlight { color: #fcd452; }
-```
-
----
-
-### 3.18 Scrollbar
-
-**Tailwind:**
-```
-scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200
-dark:scrollbar-track-coolgray-200 scrollbar-thin
-```
-
-**Plain CSS:**
-```css
-::-webkit-scrollbar { width: 6px; height: 6px; }
-::-webkit-scrollbar-track { background: #e5e5e5; }
-::-webkit-scrollbar-thumb { background: #7317ff; }
-.dark ::-webkit-scrollbar-track { background: #202020; }
-```
-
----
-
-### 3.19 Table
-
-**Plain CSS:**
-```css
-table { min-width: 100%; border-collapse: separate; }
-table, tbody { border-bottom: 1px solid #d4d4d4; }
-.dark table, .dark tbody { border-color: #202020; }
-
-thead { text-transform: uppercase; }
-
-tr { color: #000; }
-tr:hover { background: #e5e5e5; }
-.dark tr { color: #a3a3a3; }
-.dark tr:hover { background: #000; }
-
-th {
- padding: 0.875rem 0.75rem;
- text-align: left;
- color: #000;
-}
-.dark th { color: #fff; }
-th:first-child { padding-left: 1.5rem; }
-
-td { padding: 1rem 0.75rem; white-space: nowrap; }
-td:first-child { padding-left: 1.5rem; font-weight: 700; }
-```
-
----
-
-### 3.20 Keyboard Shortcut Indicator
-
-**Tailwind:**
-```
-px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning
-```
-
-**Plain CSS:**
-```css
-.kbd {
- padding: 0 0.5rem;
- font-size: 0.75rem;
- border-radius: 0.125rem;
- border: 1px dashed #404040;
-}
-.dark .kbd { color: #fcd452; }
-```
-
----
-
-## 4. Base Element Styles
-
-These global styles are applied to all HTML elements:
-
-```css
-/* Page */
-html, body {
- width: 100%;
- min-height: 100%;
- background: #f9fafb;
- font-family: Inter, sans-serif;
-}
-.dark html, .dark body {
- background: #101010;
- color: #a3a3a3;
-}
-
-body {
- min-height: 100vh;
- font-size: 0.875rem;
- -webkit-font-smoothing: antialiased;
- overflow-x: hidden;
-}
-
-/* Links */
-a:hover { color: #000; }
-.dark a:hover { color: #fff; }
-
-/* Labels */
-.dark label { color: #a3a3a3; }
-
-/* Sections */
-section { margin-bottom: 3rem; }
-
-/* Default border color override */
-*, ::after, ::before, ::backdrop {
- border-color: #202020; /* coolgray-200 */
-}
-
-/* Select options */
-.dark option {
- color: #fff;
- background: #181818;
-}
-```
-
----
-
-## 5. Interactive State Reference
-
-### Focus
-
-| Element Type | Mechanism | Light | Dark |
-|---|---|---|---|
-| Buttons, links, checkboxes | `ring-2` offset | Purple `#6b16ed` | Yellow `#fcd452` |
-| Inputs, selects, textareas | Inset box-shadow (4px left bar) | Purple `#6b16ed` | Yellow `#fcd452` |
-| Dropdown items | Background change | `bg-neutral-100` | `bg-coollabs` (`#6b16ed`) |
-
-### Hover
-
-| Element | Light | Dark |
-|---|---|---|
-| Button (default) | `bg-neutral-100` | `bg-coolgray-200` |
-| Button (highlighted) | `bg-coollabs` (`#6b16ed`) | `bg-coollabs-100` (`#7317ff`) |
-| Button (error) | `bg-red-300` | `bg-red-800` |
-| Box card | `bg-neutral-100` + all child text `#000` | `bg-coollabs-100` (`#7317ff`) + all child text `#fff` |
-| Coolbox card | Ring: `ring-coollabs` | Ring: `ring-warning` |
-| Menu item | `bg-neutral-300` | `bg-coolgray-100` |
-| Dropdown item | `bg-neutral-100` | `bg-coollabs` |
-| Table row | `bg-neutral-200` | `bg-black` |
-| Link | `text-black` | `text-white` |
-| Checkbox container | — | `bg-coolgray-100` |
-
-### Disabled
-
-```css
-/* Universal disabled pattern */
-:disabled {
- cursor: not-allowed;
- color: #d4d4d4; /* neutral-300 */
- background: transparent;
- border-color: transparent;
-}
-.dark :disabled {
- color: #525252; /* neutral-600 */
-}
-
-/* Input-specific */
-.input:disabled {
- background: #e5e5e5; /* neutral-200 */
- color: #737373; /* neutral-500 */
- box-shadow: none;
-}
-.dark .input:disabled {
- background: rgba(24, 24, 24, 0.4);
- box-shadow: none;
-}
-```
-
-### Readonly
-
-```css
-.input:read-only {
- color: #737373;
- background: #e5e5e5;
- box-shadow: none;
-}
-.dark .input:read-only {
- color: #737373;
- background: rgba(24, 24, 24, 0.4);
- box-shadow: none;
-}
-```
-
----
-
-## 6. CSS Custom Properties (Theme Tokens)
-
-For use in any CSS framework or plain CSS:
-
-```css
-:root {
- /* Font */
- --font-sans: Inter, sans-serif;
-
- /* Brand */
- --color-base: #101010;
- --color-coollabs: #6b16ed;
- --color-coollabs-50: #f5f0ff;
- --color-coollabs-100: #7317ff;
- --color-coollabs-200: #5a12c7;
- --color-coollabs-300: #4a0fa3;
-
- /* Neutral grays (dark backgrounds) */
- --color-coolgray-100: #181818;
- --color-coolgray-200: #202020;
- --color-coolgray-300: #242424;
- --color-coolgray-400: #282828;
- --color-coolgray-500: #323232;
-
- /* Warning / dark accent */
- --color-warning: #fcd452;
- --color-warning-50: #fefce8;
- --color-warning-100: #fef9c3;
- --color-warning-200: #fef08a;
- --color-warning-300: #fde047;
- --color-warning-400: #fcd452;
- --color-warning-500: #facc15;
- --color-warning-600: #ca8a04;
- --color-warning-700: #a16207;
- --color-warning-800: #854d0e;
- --color-warning-900: #713f12;
-
- /* Semantic */
- --color-success: #22C55E;
- --color-error: #dc2626;
-}
-```
diff --git a/.env.development.example b/.env.development.example
index 594b89201..d02b8ba59 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal
DB_PORT=5432
+# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
+# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
+# DB_READ_HOST=replica1,replica2
+# DB_READ_PORT=5432
+# DB_READ_USERNAME=coolify
+# DB_READ_PASSWORD=
+# DB_WRITE_HOST=
+# DB_WRITE_PORT=5432
+# DB_WRITE_USERNAME=coolify
+# DB_WRITE_PASSWORD=
+# DB_STICKY=true
+
# Ray Configuration
# Set to true to enable Ray
RAY_ENABLED=false
diff --git a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml
index 42df4785e..f0c77577e 100644
--- a/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml
+++ b/.github/ISSUE_TEMPLATE/01_BUG_REPORT.yml
@@ -9,9 +9,6 @@ body:
> [!IMPORTANT]
> **Please ensure you are using the latest version of Coolify before submitting an issue, as the bug may have already been fixed in a recent update.** (Of course, if you're experiencing an issue on the latest version that wasn't present in a previous version, please let us know.)
- # 💎 Bounty Program (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- - If you would like to prioritize the issue resolution, consider adding a bounty to this issue through our [Bounty Program](https://console.algora.io/org/coollabsio/bounties/new).
-
- type: textarea
attributes:
label: Error Message and Logs
diff --git a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml b/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml
deleted file mode 100644
index ef26125e0..000000000
--- a/.github/ISSUE_TEMPLATE/02_ENHANCEMENT_BOUNTY.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: 💎 Enhancement Bounty
-description: "Propose a new feature, service, or improvement with an attached bounty."
-title: "[Enhancement]: "
-labels: ["✨ Enhancement", "🔍 Triage"]
-body:
- - type: markdown
- attributes:
- value: |
- > [!IMPORTANT]
- > **This issue template is exclusively for proposing new features, services, or improvements with an attached bounty.** Enhancements without a bounty can be discussed in the appropriate category of [Github Discussions](https://github.com/coollabsio/coolify/discussions).
-
- # 💎 Add a Bounty (with [algora.io](https://console.algora.io/org/coollabsio/bounties/new))
- - [Click here to add the required bounty](https://console.algora.io/org/coollabsio/bounties/new)
-
- - type: dropdown
- attributes:
- label: Request Type
- description: Select the type of request you are making.
- options:
- - New Feature
- - New Service
- - Improvement
- validations:
- required: true
-
- - type: textarea
- attributes:
- label: Description
- description: Provide a detailed description of the feature, improvement, or service you are proposing.
- validations:
- required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 7fd2c358e..e1286eb22 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -22,7 +22,7 @@
## Preview
-
+
## AI Assistance
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/AGENTS.md b/AGENTS.md
index 3fff0074e..2c403efe8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,3 +1,7 @@
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
=== foundation rules ===
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..188889954 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.
@@ -43,7 +47,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/CONTRIBUTING.md b/CONTRIBUTING.md
index 9aec08420..85fceb28f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -212,7 +212,7 @@ To maintain high-quality contributions and efficient review process:
- Duplicate or superseded work
- Security or quality concerns
-#### Code Quality, Testing, and Bounty Submissions
+#### Code Quality and Testing
All contributions must adhere to the highest standards of code quality and testing:
- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged.
@@ -220,15 +220,6 @@ All contributions must adhere to the highest standards of code quality and testi
- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase.
- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately.
-**For PRs that claim bounties:**
-
-- **Eligibility**: Bounty PRs must strictly follow all guidelines above. Untested, poorly described, or non-compliant PRs will not qualify for bounty rewards.
-- **Original Work**: Bounties are for genuine contributions. Submitting AI-generated or copied code solely for bounty claims will result in disqualification and potential removal from contributing.
-- **Quality Standards**: Bounty submissions are held to even higher standards. Ensure comprehensive testing, clear documentation, and alignment with project goals. When maintainers review the changes, they should work as expected (the things mentioned in the PR description plus what the bounty issuer needs).
-- **Claim Process**: Only successfully merged PRs that pass all reviews (core maintainers + bounty issuer) and meet bounty criteria will be awarded. Follow the issue's bounty guidelines precisely.
-- **Prioritization**: Contributor PRs are prioritized over first-time or new contributors.
-- **Developer Experience**: We highly advise beginners to avoid participating in bug bounties for our codebase. Most of the time, they don't know what they are changing, how it affects other parts of the system, or if their changes are even correct.
-- **Review Comments**: When maintainers ask questions, you should be able to respond properly without generic or AI-generated fluff.
## Development Notes
diff --git a/README.md b/README.md
index a5aa69343..b387d87e8 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
An open-source & self-hostable Heroku / Netlify / Vercel alternative.
 [](https://console.algora.io/org/coollabsio/bounties/new)
+)
## About the Project
@@ -59,24 +59,23 @@ Thank you so much!
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
+* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
-*
+* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
### Big Sponsors
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
-* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
-* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
+* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
-* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
@@ -88,6 +87,7 @@ Thank you so much!
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
+* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
@@ -152,6 +152,10 @@ Thank you so much!
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index e86e30f04..b79709c5a 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -36,10 +36,11 @@ class StopApplication
: getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray();
+ $timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php
index bf9fdee72..09de9b628 100644
--- a/app/Actions/Application/StopApplicationOneServer.php
+++ b/app/Actions/Application/StopApplicationOneServer.php
@@ -20,13 +20,15 @@ class StopApplicationOneServer
}
try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
+ $timeout = $application->settings->stopGracePeriodSeconds();
+
if ($containers->count() > 0) {
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
instant_remote_process(
[
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
],
$server
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/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 393906b9b..525e736c3 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -50,13 +50,9 @@ class StartClickhouse
],
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -98,6 +94,9 @@ class StartClickhouse
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php
index e2fa6fc87..4b55b0c1d 100644
--- a/app/Actions/Database/StartDatabase.php
+++ b/app/Actions/Database/StartDatabase.php
@@ -11,12 +11,16 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
@@ -25,28 +29,28 @@ class StartDatabase
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
- case \App\Models\StandalonePostgresql::class:
+ case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
- case \App\Models\StandaloneRedis::class:
+ case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
- case \App\Models\StandaloneMongodb::class:
+ case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
- case \App\Models\StandaloneMysql::class:
+ case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
- case \App\Models\StandaloneMariadb::class:
+ case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
- case \App\Models\StandaloneKeydb::class:
+ case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
- case \App\Models\StandaloneDragonfly::class:
+ case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
- case \App\Models\StandaloneClickhouse::class:
+ case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
}
diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php
index fa39f7909..1057d1e4d 100644
--- a/app/Actions/Database/StartDatabaseProxy.php
+++ b/app/Actions/Database/StartDatabaseProxy.php
@@ -11,14 +11,19 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{
@@ -29,7 +34,7 @@ class StartDatabaseProxy
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
- if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
@@ -132,7 +137,7 @@ class StartDatabaseProxy
?? data_get($database, 'service.environment.project.team');
$team?->notify(
- new \App\Notifications\Container\ContainerRestarted(
+ new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index cd820523d..b78a0987d 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -106,13 +106,9 @@ class StartDragonfly
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -182,6 +178,9 @@ class StartDragonfly
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index fe80a7d54..89258fe24 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -108,13 +108,9 @@ class StartKeydb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -166,7 +162,7 @@ class StartKeydb
$docker_compose['volumes'] = $volume_names;
}
- if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
+ if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@@ -197,6 +193,9 @@ class StartKeydb
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 498ba0b0b..2e8faea9a 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -103,13 +103,9 @@ class StartMariadb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -175,7 +171,7 @@ class StartMariadb
);
}
- if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
+ if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[
@@ -202,6 +198,9 @@ class StartMariadb
];
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 9565990c1..80ec812a1 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -109,17 +109,11 @@ class StartMongodb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD',
- 'echo',
- 'ok',
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD',
+ 'echo',
+ 'ok',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -253,6 +247,9 @@ class StartMongodb
$docker_compose['services'][$container_name]['command'] = $commandParts;
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -340,7 +337,10 @@ class StartMongodb
private function add_default_database()
{
- $content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
+ $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
+ $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
+ $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
+ $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 337516405..0445bddcd 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -103,13 +103,9 @@ class StartMysql
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -175,7 +171,7 @@ class StartMysql
);
}
- if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
+ if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@@ -203,6 +199,9 @@ class StartMysql
];
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -215,7 +214,8 @@ class StartMysql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
+ $mysqlUser = escapeshellarg($this->database->mysql_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
}
$this->commands[] = "echo 'Database started.'";
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 41e39c811..ae7ae9860 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -110,16 +110,9 @@ class StartPostgresql
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD-SHELL',
- "psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -216,6 +209,9 @@ class StartPostgresql
$docker_compose['services'][$container_name]['command'] = $command;
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -227,7 +223,8 @@ class StartPostgresql
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
+ $postgresUser = escapeshellarg($this->database->postgres_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
}
$this->commands[] = "echo 'Database started.'";
@@ -304,9 +301,18 @@ class StartPostgresql
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
+
+ // Normalise filename without rejecting legacy values so previously created
+ // init scripts keep deploying. basename() strips any directory components
+ // (path traversal) and escapeshellarg() contains every shell metacharacter
+ // in the tee target. Livewire / API validate new filenames up front.
+ $filename = basename((string) $filename);
+
+ $target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $escaped_target = escapeshellarg($target_path);
$content_base64 = base64_encode($content);
- $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
- $this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
+ $this->init_scripts[] = $target_path;
}
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 70df91054..64b434821 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -105,17 +105,11 @@ class StartRedis
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD-SHELL',
- 'redis-cli',
- 'ping',
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD-SHELL',
+ 'redis-cli',
+ 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -181,7 +175,7 @@ class StartRedis
);
}
- if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
+ if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf',
@@ -194,6 +188,9 @@ class StartRedis
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 9f97dd0d4..cddf66389 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
+use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -37,13 +38,17 @@ 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']),
]);
- $team = $user->teams()->first();
+ $user->save();
+ $team = $user->teams()->first() ?? Team::find(0);
+ if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
// Disable registration after first user is created
$settings = instanceSettings();
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/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 0d9ca0153..06abeb3a6 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -48,9 +48,10 @@ class CleanupDocker
);
$commands = [
- 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
+ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
+ "docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
diff --git a/app/Actions/Server/ResourcesCheck.php b/app/Actions/Server/ResourcesCheck.php
deleted file mode 100644
index e6b90ba38..000000000
--- a/app/Actions/Server/ResourcesCheck.php
+++ /dev/null
@@ -1,41 +0,0 @@
-subSeconds($seconds))->update(['status' => 'exited']);
- ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- } catch (\Throwable $e) {
- return handleError($e);
- }
- }
-}
diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php
index e4df5a061..eb419992d 100644
--- a/app/Actions/Server/StartLogDrain.php
+++ b/app/Actions/Server/StartLogDrain.php
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
+use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain
@@ -201,10 +202,29 @@ Files:
"echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d",
];
+ $command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
return instant_remote_process($command, $server);
} catch (\Throwable $e) {
return handleError($e);
}
}
+
+ private function logDrainNetworkConnectCommands(Server $server): array
+ {
+ if (! $server->isLogDrainEnabled()) {
+ return [];
+ }
+
+ return $server->services()
+ ->with('destination')
+ ->where('connect_to_docker_network', true)
+ ->get()
+ ->map(fn (Service $service) => data_get($service, 'destination.network'))
+ ->filter()
+ ->unique()
+ ->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
+ ->values()
+ ->all();
+ }
}
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 071f3ec46..289ab9ebe 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -4,7 +4,6 @@ namespace App\Actions\Server;
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ class StartSentinel
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
- $token = data_get($server, 'settings.sentinel_token');
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
- }
+ $token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
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/RestartService.php b/app/Actions/Service/RestartService.php
index d38ef54d6..6acd3b0a4 100644
--- a/app/Actions/Service/RestartService.php
+++ b/app/Actions/Service/RestartService.php
@@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages)
{
- StopService::run($service);
-
- return StartService::run($service, $pullLatestImages);
+ return StartService::run(
+ service: $service,
+ pullLatestImages: $pullLatestImages,
+ stopBeforeStart: true,
+ );
}
}
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index 6b5e1d4ac..463a8ad5b 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -4,18 +4,22 @@ namespace App\Actions\Service;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{
$service->parse();
- if ($stopBeforeStart) {
+ if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false);
}
$service->saveComposeConfigs();
@@ -40,13 +44,40 @@ 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";
}
}
+ $commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
+
+ private function logDrainNetworkConnectCommands(Service $service): array
+ {
+ if (! data_get($service, 'connect_to_docker_network')) {
+ return [];
+ }
+
+ if (! $service->destination?->server?->isLogDrainEnabled()) {
+ return [];
+ }
+
+ $network = data_get($service, 'destination.network');
+
+ if (blank($network)) {
+ return [];
+ }
+
+ return [
+ 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
+ ];
+ }
+
+ private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
+ {
+ return $stopBeforeStart && ! $pullLatestImages;
+ }
}
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/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php
index d572db9e7..b2b06e7ba 100644
--- a/app/Actions/User/DeleteUserTeams.php
+++ b/app/Actions/User/DeleteUserTeams.php
@@ -137,9 +137,11 @@ class DeleteUserTeams
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
+ RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team
$team->members()->detach($this->user->id);
+ RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['transferred']++;
} catch (\Exception $e) {
@@ -152,6 +154,7 @@ class DeleteUserTeams
foreach ($preview['to_leave'] as $team) {
try {
$team->members()->detach($this->user->id);
+ RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['left']++;
} catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
diff --git a/app/Actions/User/RevokeUserTeamTokens.php b/app/Actions/User/RevokeUserTeamTokens.php
new file mode 100644
index 000000000..9aadf1eeb
--- /dev/null
+++ b/app/Actions/User/RevokeUserTeamTokens.php
@@ -0,0 +1,43 @@
+where('tokenable_id', self::userId($user))
+ ->where('team_id', $teamId)
+ ->delete();
+ }
+
+ public static function forUser(User|int $user): int
+ {
+ return self::baseQuery()
+ ->where('tokenable_id', self::userId($user))
+ ->delete();
+ }
+
+ public static function forTeam(int|string $teamId): int
+ {
+ return self::baseQuery()
+ ->where('team_id', $teamId)
+ ->delete();
+ }
+
+ private static function baseQuery(): Builder
+ {
+ return PersonalAccessToken::query()
+ ->where('tokenable_type', User::class);
+ }
+
+ private static function userId(User|int $user): int
+ {
+ return $user instanceof User ? $user->id : $user;
+ }
+}
diff --git a/app/Casts/EncryptedArrayCast.php b/app/Casts/EncryptedArrayCast.php
new file mode 100644
index 000000000..4f72c6286
--- /dev/null
+++ b/app/Casts/EncryptedArrayCast.php
@@ -0,0 +1,51 @@
+|null, array|null>
+ */
+class EncryptedArrayCast implements CastsAttributes
+{
+ /**
+ * @param array $attributes
+ * @return array|null
+ */
+ public function get(Model $model, string $key, mixed $value, array $attributes): ?array
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ try {
+ $value = Crypt::decryptString($value);
+ } catch (DecryptException) {
+ // Legacy plaintext JSON written before this column was encrypted.
+ }
+
+ $decoded = json_decode((string) $value, true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public function set(Model $model, string $key, mixed $value, array $attributes): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php
index 09563a2c3..666e98a18 100644
--- a/app/Console/Commands/CleanupUnreachableServers.php
+++ b/app/Console/Commands/CleanupUnreachableServers.php
@@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command
if ($servers->count() > 0) {
foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name";
- $server->update([
- 'ip' => '1.2.3.4',
- ]);
+ if (isCloud()) {
+ $server->update([
+ 'ip' => '1.2.3.4',
+ ]);
+ } else {
+ $server->forceDisableServer();
+ }
}
}
}
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/Generate/Services.php b/app/Console/Commands/Generate/Services.php
index 42f9360bb..e316fc391 100644
--- a/app/Console/Commands/Generate/Services.php
+++ b/app/Console/Commands/Generate/Services.php
@@ -88,6 +88,14 @@ class Services extends Command
$payload['envs'] = base64_encode($envFileContent);
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
@@ -160,6 +168,14 @@ class Services extends Command
$payload['envs'] = base64_encode($modifiedEnvContent);
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
@@ -229,6 +245,14 @@ class Services extends Command
$payload['envs'] = $modifiedEnvContent;
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
}
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..4783df072 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");
}
}
}
@@ -252,7 +253,7 @@ class Init extends Command
'save_s3' => false,
'frequency' => '0 0 * * *',
'database_id' => $database->id,
- 'database_type' => \App\Models\StandalonePostgresql::class,
+ 'database_type' => StandalonePostgresql::class,
'team_id' => 0,
]);
}
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/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 9ac3371e0..d6d77f22e 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
- protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
+ protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/**
* The console command description.
@@ -25,650 +25,6 @@ class SyncBunny extends Command
*/
protected $description = 'Sync files to BunnyCDN';
- /**
- * Fetch GitHub releases and sync to GitHub repository
- */
- private function syncReleasesToGitHubRepo(): bool
- {
- $this->info('Fetching releases from GitHub...');
- try {
- $response = Http::timeout(30)
- ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
- 'per_page' => 30, // Fetch more releases for better changelog
- ]);
-
- if (! $response->successful()) {
- $this->error('Failed to fetch releases from GitHub: '.$response->status());
-
- return false;
- }
-
- $releases = $response->json();
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
- $branchName = 'update-releases-'.$timestamp;
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Write releases.json
- $this->info('Writing releases.json...');
- $releasesPath = "$tmpDir/json/releases.json";
- $releasesDir = dirname($releasesPath);
-
- // Ensure directory exists
- if (! is_dir($releasesDir)) {
- $this->info("Creating directory: $releasesDir");
- if (! mkdir($releasesDir, 0755, true)) {
- $this->error("Failed to create directory: $releasesDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $bytesWritten = file_put_contents($releasesPath, $jsonContent);
-
- if ($bytesWritten === false) {
- $this->error("Failed to write releases.json to: $releasesPath");
- $this->error('Possible reasons: permission denied or disk full.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Stage and commit
- $this->info('Committing changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('Releases are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
- $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR Output: '.implode("\n", $output));
- }
- $this->info('Total releases synced: '.count($releases));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing releases: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync both releases.json and versions.json to GitHub repository in one PR
- */
- private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
- {
- $this->info('Syncing releases.json and versions.json to GitHub repository...');
- try {
- // 1. Fetch releases from GitHub API
- $this->info('Fetching releases from GitHub API...');
- $response = Http::timeout(30)
- ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
- 'per_page' => 30,
- ]);
-
- if (! $response->successful()) {
- $this->error('Failed to fetch releases from GitHub: '.$response->status());
-
- return false;
- }
-
- $releases = $response->json();
-
- // 2. Read versions.json
- if (! file_exists($versionsLocation)) {
- $this->error("versions.json not found at: $versionsLocation");
-
- return false;
- }
-
- $file = file_get_contents($versionsLocation);
- $versionsJson = json_decode($file, true);
- $actualVersion = data_get($versionsJson, 'coolify.v4.version');
-
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
- $branchName = 'update-releases-and-versions-'.$timestamp;
- $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
-
- // 3. Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // 4. Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 5. Write releases.json
- $this->info('Writing releases.json...');
- $releasesPath = "$tmpDir/json/releases.json";
- $releasesDir = dirname($releasesPath);
-
- if (! is_dir($releasesDir)) {
- if (! mkdir($releasesDir, 0755, true)) {
- $this->error("Failed to create directory: $releasesDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
- $this->error("Failed to write releases.json to: $releasesPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 6. Write versions.json
- $this->info('Writing versions.json...');
- $versionsPath = "$tmpDir/$versionsTargetPath";
- $versionsDir = dirname($versionsPath);
-
- if (! is_dir($versionsDir)) {
- if (! mkdir($versionsDir, 0755, true)) {
- $this->error("Failed to create directory: $versionsDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
- $this->error("Failed to write versions.json to: $versionsPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 7. Stage both files
- $this->info('Staging changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 8. Check for changes
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('Both files are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // 9. Commit changes
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 10. Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 11. Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // 12. Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info("Version synced: $actualVersion");
- $this->info('Total releases synced: '.count($releases));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing to GitHub: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync install.sh, docker-compose, and env files to GitHub repository via PR
- */
- private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
- {
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("Syncing $envLabel files to GitHub repository...");
- try {
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
- $branchName = 'update-files-'.$timestamp;
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Copy each file to its target path in the CDN repo
- $copiedFiles = [];
- foreach ($files as $sourceFile => $targetPath) {
- if (! file_exists($sourceFile)) {
- $this->warn("Source file not found, skipping: $sourceFile");
-
- continue;
- }
-
- $destPath = "$tmpDir/$targetPath";
- $destDir = dirname($destPath);
-
- if (! is_dir($destDir)) {
- if (! mkdir($destDir, 0755, true)) {
- $this->error("Failed to create directory: $destDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- if (copy($sourceFile, $destPath) === false) {
- $this->error("Failed to copy $sourceFile to $destPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $copiedFiles[] = $targetPath;
- $this->info("Copied: $targetPath");
- }
-
- if (empty($copiedFiles)) {
- $this->warn('No files were copied. Nothing to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // Stage all copied files
- $this->info('Staging changes...');
- $output = [];
- $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
- exec($stageCmd, $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Check for changes
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('All files are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // Commit changes
- $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
- $fileList = implode("\n- ", $copiedFiles);
- $prBody = "Automated update of $envLabel files:\n- $fileList";
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info('Files synced: '.count($copiedFiles));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing files to GitHub: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync versions.json to GitHub repository via PR
- */
- private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
- {
- $this->info('Syncing versions.json to GitHub repository...');
- try {
- if (! file_exists($versionsLocation)) {
- $this->error("versions.json not found at: $versionsLocation");
-
- return false;
- }
-
- $file = file_get_contents($versionsLocation);
- $json = json_decode($file, true);
- $actualVersion = data_get($json, 'coolify.v4.version');
-
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
- $branchName = 'update-versions-'.$timestamp;
- $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Write versions.json
- $this->info('Writing versions.json...');
- $versionsPath = "$tmpDir/$targetPath";
- $versionsDir = dirname($versionsPath);
-
- // Ensure directory exists
- if (! is_dir($versionsDir)) {
- $this->info("Creating directory: $versionsDir");
- if (! mkdir($versionsDir, 0755, true)) {
- $this->error("Failed to create directory: $versionsDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $bytesWritten = file_put_contents($versionsPath, $jsonContent);
-
- if ($bytesWritten === false) {
- $this->error("Failed to write versions.json to: $versionsPath");
- $this->error('Possible reasons: permission denied or disk full.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Stage and commit
- $this->info('Committing changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('versions.json is already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $prBody = "Automated update of $envLabel versions.json to version $actualVersion";
- $output = [];
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info("Version synced: $actualVersion");
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing versions.json: '.$e->getMessage());
-
- return false;
- }
- }
-
/**
* Execute the console command.
*/
@@ -677,8 +33,6 @@ class SyncBunny extends Command
$that = $this;
$only_template = $this->option('templates');
$only_version = $this->option('release');
- $only_github_releases = $this->option('github-releases');
- $only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@@ -736,30 +90,11 @@ class SyncBunny extends Command
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
- if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
+ if (! $only_template && ! $only_version) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
+ $this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine();
- // Build file mapping for diff
- if ($nightly) {
- $fileMapping = [
- $compose_file_location => 'docker/nightly/docker-compose.yml',
- $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
- $production_env_location => 'environment/nightly/.env.production',
- $upgrade_script_location => 'scripts/nightly/upgrade.sh',
- $install_script_location => 'scripts/nightly/install.sh',
- ];
- } else {
- $fileMapping = [
- $compose_file_location => 'docker/docker-compose.yml',
- $compose_file_prod_location => 'docker/docker-compose.prod.yml',
- $production_env_location => 'environment/.env.production',
- $upgrade_script_location => 'scripts/upgrade.sh',
- $install_script_location => 'scripts/install.sh',
- ];
- }
-
// BunnyCDN file mapping (local file => CDN URL path)
$bunnyFileMapping = [
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
@@ -812,44 +147,6 @@ class SyncBunny extends Command
}
}
- // Diff against GitHub coolify-cdn repo
- $this->newLine();
- $this->info('Fetching coolify-cdn repo to compare...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
-
- if ($returnCode === 0) {
- foreach ($fileMapping as $localFile => $cdnPath) {
- $remotePath = "$diffTmpDir/repo/$cdnPath";
- if (! file_exists($localFile)) {
- continue;
- }
- if (! file_exists($remotePath)) {
- $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
- $hasChanges = true;
-
- continue;
- }
-
- $diffOutput = [];
- exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
- if ($diffCode !== 0) {
- $hasChanges = true;
- $this->newLine();
- $this->info("--- GitHub: $cdnPath");
- $this->info("+++ Local: $cdnPath");
- foreach ($diffOutput as $line) {
- if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
- continue;
- }
- $this->line($line);
- }
- }
- }
- } else {
- $this->warn('Could not fetch coolify-cdn repo for diff.');
- }
-
exec('rm -rf '.escapeshellarg($diffTmpDir));
if (! $hasChanges) {
@@ -881,9 +178,9 @@ class SyncBunny extends Command
return;
} elseif ($only_version) {
if ($nightly) {
- $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
+ $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
+ $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
@@ -891,8 +188,7 @@ class SyncBunny extends Command
$this->info("Version: {$actual_version}");
$this->info('This will:');
- $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
- $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
+ $this->info(' 1. Sync versions.json to BunnyCDN');
$this->newLine();
$confirmed = confirm('Are you sure you want to proceed?');
@@ -900,8 +196,7 @@ class SyncBunny extends Command
return;
}
- // 1. Sync versions.json to BunnyCDN (deprecated but still needed)
- $this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
+ $this->info('Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@@ -909,46 +204,8 @@ class SyncBunny extends Command
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine();
- // 2. Create GitHub PR with both releases.json and versions.json
- $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
- $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
- if ($githubSuccess) {
- $this->info('✓ GitHub PR created successfully with both files');
- } else {
- $this->error('✗ Failed to create GitHub PR');
- }
- $this->newLine();
-
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete');
- $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
-
- return;
- } elseif ($only_github_releases) {
- $this->info('About to sync GitHub releases to GitHub repository.');
- $confirmed = confirm('Are you sure you want to sync GitHub releases?');
- if (! $confirmed) {
- return;
- }
-
- // Sync releases to GitHub repository
- $this->syncReleasesToGitHubRepo();
-
- return;
- } elseif ($only_github_versions) {
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $file = file_get_contents($versions_location);
- $json = json_decode($file, true);
- $actual_version = data_get($json, 'coolify.v4.version');
-
- $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
- $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
- if (! $confirmed) {
- return;
- }
-
- // Sync versions.json to GitHub repository
- $this->syncVersionsToGitHubRepo($versions_location, $nightly);
return;
}
@@ -970,31 +227,8 @@ class SyncBunny extends Command
$this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine();
- // Sync files to GitHub CDN repository via PR
- $this->info('Creating GitHub PR for coolify-cdn repository...');
- if ($nightly) {
- $files = [
- $compose_file_location => 'docker/nightly/docker-compose.yml',
- $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
- $production_env_location => 'environment/nightly/.env.production',
- $upgrade_script_location => 'scripts/nightly/upgrade.sh',
- $install_script_location => 'scripts/nightly/install.sh',
- ];
- } else {
- $files = [
- $compose_file_location => 'docker/docker-compose.yml',
- $compose_file_prod_location => 'docker/docker-compose.prod.yml',
- $production_env_location => 'environment/.env.production',
- $upgrade_script_location => 'scripts/upgrade.sh',
- $install_script_location => 'scripts/install.sh',
- ];
- }
-
- $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
- $this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete');
- $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php
index 9ecf90716..b6e9a6121 100644
--- a/app/Console/Commands/ViewScheduledLogs.php
+++ b/app/Console/Commands/ViewScheduledLogs.php
@@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
public function handle()
{
$date = $this->option('date') ?: now()->format('Y-m-d');
+ if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
+
+ return self::INVALID;
+ }
$logPaths = $this->getLogPaths($date);
if (empty($logPaths)) {
@@ -49,17 +54,19 @@ class ViewScheduledLogs extends Command
$this->line('');
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -f {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPath}");
}
} else {
// Multiple files - use multitail or tail with process substitution
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPathsStr}");
}
@@ -68,20 +75,23 @@ class ViewScheduledLogs extends Command
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line('');
+ $escapedLines = escapeshellarg((string) $lines);
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPath}");
+ passthru("tail -n {$escapedLines} {$logPath}");
}
} else {
// Multiple files - concatenate and sort by timestamp
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPathsStr} | sort");
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
}
}
}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index c5e12b7ee..e6dc32383 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -2,11 +2,13 @@
namespace App\Console;
+use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
+use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
@@ -39,8 +41,13 @@ class Kernel extends ConsoleKernel
$this->instanceTimezone = config('app.timezone');
}
- // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
+ $this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
+ ->name('cleanup:ssh-mux')
+ ->hourly()
+ ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
+ $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
+ $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs
@@ -75,7 +82,7 @@ class Kernel extends ConsoleKernel
// Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
- $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
+ $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
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/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php
index cb51db6d6..eee898823 100644
--- a/app/Enums/BuildPackTypes.php
+++ b/app/Enums/BuildPackTypes.php
@@ -8,4 +8,5 @@ enum BuildPackTypes: string
case STATIC = 'static';
case DOCKERFILE = 'dockerfile';
case DOCKERCOMPOSE = 'dockercompose';
+ case RAILPACK = 'railpack';
}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 71de48bcd..58f21c793 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -4,8 +4,10 @@ namespace App\Exceptions;
use App\Models\InstanceSettings;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
- * @var array, \Psr\Log\LogLevel::*>
+ * @var array, LogLevel::*>
*/
protected $levels = [
//
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
- * @var array>
+ * @var array>
*/
protected $dontReport = [
ProcessException::class,
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $exception->getMessage(),
+ 'guards' => $exception->guards(),
+ ], 'warning');
+ }
+
return response()->json(['message' => $exception->getMessage()], 401);
}
@@ -61,8 +70,15 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.policy_denied', [
+ 'reason' => $e->getMessage(),
+ 'route' => $request->route()?->getName() ?? $request->path(),
+ ], 'warning');
+ }
+
// Get the custom message from the policy if available
$message = $e->getMessage();
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index aa9d06996..907cb4456 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -4,6 +4,7 @@ namespace App\Helpers;
use App\Models\PrivateKey;
use App\Models\Server;
+use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
@@ -12,15 +13,13 @@ use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
- public static function serverSshConfiguration(Server $server)
+ public static function serverSshConfiguration(Server $server): array
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
- $sshKeyLocation = $privateKey->getKeyLocation();
- $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [
- 'sshKeyLocation' => $sshKeyLocation,
- 'muxFilename' => $muxFilename,
+ 'sshKeyLocation' => $privateKey->getKeyLocation(),
+ 'muxFilename' => self::muxSocket($server),
];
}
@@ -30,40 +29,39 @@ class SshMultiplexingHelper
return false;
}
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
-
- // Check if connection exists
- $checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $checkCommand .= self::escapedUserAtHost($server);
- $process = Process::run($checkCommand);
-
- if ($process->exitCode() !== 0) {
- return self::establishNewMultiplexedConnection($server);
+ if (self::connectionIsReusable($server)) {
+ return true;
}
- // Connection exists, ensure we have metadata for age tracking
- if (self::getConnectionAge($server) === null) {
- // Existing connection but no metadata, store current time as fallback
- self::storeConnectionMetadata($server);
- }
+ try {
+ return Cache::lock(
+ self::connectionLockKey($server),
+ config('constants.ssh.mux_lock_ttl')
+ )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
+ if (self::connectionIsReusable($server)) {
+ return true;
+ }
- // Connection exists, check if it needs refresh due to age
- if (self::isConnectionExpired($server)) {
- return self::refreshMultiplexedConnection($server);
- }
+ if (self::masterConnectionExists($server)) {
+ return self::refreshMultiplexedConnection($server);
+ }
- // Perform health check if enabled
- if (config('constants.ssh.mux_health_check_enabled')) {
- if (! self::isConnectionHealthy($server)) {
- return self::refreshMultiplexedConnection($server);
- }
- }
+ return self::establishNewMultiplexedConnection($server);
+ });
+ } catch (LockTimeoutException) {
+ Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ ]);
- return true;
+ return false;
+ } catch (\Throwable $e) {
+ Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
}
public static function establishNewMultiplexedConnection(Server $server): bool
@@ -71,86 +69,72 @@ class SshMultiplexingHelper
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
- $connectionTimeout = config('constants.ssh.connection_timeout');
+ $connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
- $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
+
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server);
+
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
}
- // Store connection metadata for tracking
self::storeConnectionMetadata($server);
return true;
}
- public static function removeMuxFile(Server $server)
+ public static function removeMuxFile(Server $server): void
{
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
-
- $closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $closeCommand .= self::escapedUserAtHost($server);
- Process::run($closeCommand);
-
- // Clear connection metadata from cache
+ Process::run(self::muxControlCommand($server, 'exit'));
self::clearConnectionMetadata($server);
}
- public static function generateScpCommand(Server $server, string $source, string $dest)
+ public static function generateScpCommand(Server $server, string $source, string $dest): string
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
- $muxSocket = $sshConfig['muxFilename'];
+ $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
- $timeout = config('constants.ssh.command_timeout');
- $muxPersistTime = config('constants.ssh.mux_persist_time');
-
- $scp_command = "timeout $timeout scp ";
if ($server->isIpv6()) {
- $scp_command .= '-6 ';
+ $scpCommand .= '-6 ';
}
+
if (self::isMultiplexingEnabled()) {
try {
if (self::ensureMultiplexedConnection($server)) {
- $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ $scpCommand .= self::multiplexingOptions($server);
}
- } catch (\Exception $e) {
+ } catch (\Throwable $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
- // Continue without multiplexing
}
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
+ $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
+
if ($server->isIpv6()) {
- $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
- } else {
- $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
+ return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
}
- return $scp_command;
+ return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
}
- public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
+ public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
@@ -161,40 +145,139 @@ class SshMultiplexingHelper
self::validateSshKey($server->privateKey);
- $muxSocket = $sshConfig['muxFilename'];
+ $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
+ $sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
- $timeout = config('constants.ssh.command_timeout');
- $muxPersistTime = config('constants.ssh.mux_persist_time');
-
- $ssh_command = "timeout $timeout ssh ";
-
- $multiplexingSuccessful = false;
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try {
- $multiplexingSuccessful = self::ensureMultiplexedConnection($server);
- if ($multiplexingSuccessful) {
- $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ if (self::ensureMultiplexedConnection($server)) {
+ $sshCommand .= self::multiplexingOptions($server);
}
- } catch (\Exception $e) {
- // Continue without multiplexing
+ } catch (\Throwable $e) {
+ Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ 'error' => $e->getMessage(),
+ ]);
}
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
+ $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
- $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
+ $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
- $delimiter = Hash::make($command);
- $delimiter = base64_encode($delimiter);
+ $delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
- $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
+ return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
+ }
- return $ssh_command;
+ public static function getConnectionTimeout(Server $server): int
+ {
+ $timeout = data_get($server, 'settings.connection_timeout');
+
+ return is_numeric($timeout) && (int) $timeout > 0
+ ? (int) $timeout
+ : (int) config('constants.ssh.connection_timeout');
+ }
+
+ public static function isConnectionHealthy(Server $server): bool
+ {
+ $sshConfig = self::serverSshConfiguration($server);
+ $muxSocket = $sshConfig['muxFilename'];
+ $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
+
+ $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
+ if (data_get($server, 'settings.is_cloudflare_tunnel')) {
+ $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ }
+ $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
+
+ $process = Process::run($healthCommand);
+
+ return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
+ }
+
+ public static function isConnectionExpired(Server $server): bool
+ {
+ $connectionAge = self::getConnectionAge($server);
+ $maxAge = config('constants.ssh.mux_max_age');
+
+ return $connectionAge !== null && $connectionAge > $maxAge;
+ }
+
+ public static function getConnectionAge(Server $server): ?int
+ {
+ $connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
+
+ if ($connectionTime === null) {
+ return null;
+ }
+
+ return time() - $connectionTime;
+ }
+
+ public static function refreshMultiplexedConnection(Server $server): bool
+ {
+ self::removeMuxFile($server);
+
+ return self::establishNewMultiplexedConnection($server);
+ }
+
+ private static function connectionLockKey(Server $server): string
+ {
+ return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
+ }
+
+ private static function masterConnectionExists(Server $server): bool
+ {
+ return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
+ }
+
+ private static function connectionIsReusable(Server $server): bool
+ {
+ if (! self::masterConnectionExists($server)) {
+ return false;
+ }
+
+ if (self::getConnectionAge($server) === null) {
+ self::storeConnectionMetadata($server);
+ }
+
+ if (self::isConnectionExpired($server)) {
+ return false;
+ }
+
+ if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static function muxControlCommand(Server $server, string $operation): string
+ {
+ $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
+ if (data_get($server, 'settings.is_cloudflare_tunnel')) {
+ $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ }
+
+ return $command.self::escapedUserAtHost($server);
+ }
+
+ private static function multiplexingOptions(Server $server): string
+ {
+ return '-o ControlMaster=auto '
+ .'-o ControlPath='.self::muxSocket($server).' '
+ .'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
+ }
+
+ private static function muxSocket(Server $server): string
+ {
+ return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
}
private static function escapedUserAtHost(Server $server): string
@@ -231,7 +314,6 @@ class SshMultiplexingHelper
$privateKey->storeInFileSystem();
}
- // Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@@ -253,90 +335,20 @@ class SshMultiplexingHelper
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
- // Bruh
if ($isScp) {
- $options .= '-P '.escapeshellarg((string) $server->port).' ';
- } else {
- $options .= '-p '.escapeshellarg((string) $server->port).' ';
+ return $options.'-P '.escapeshellarg((string) $server->port).' ';
}
- return $options;
+ return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
- /**
- * Check if the multiplexed connection is healthy by running a test command
- */
- public static function isConnectionHealthy(Server $server): bool
- {
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
- $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
-
- $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
-
- $process = Process::run($healthCommand);
- $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
-
- return $isHealthy;
- }
-
- /**
- * Check if the connection has exceeded its maximum age
- */
- public static function isConnectionExpired(Server $server): bool
- {
- $connectionAge = self::getConnectionAge($server);
- $maxAge = config('constants.ssh.mux_max_age');
-
- return $connectionAge !== null && $connectionAge > $maxAge;
- }
-
- /**
- * Get the age of the current connection in seconds
- */
- public static function getConnectionAge(Server $server): ?int
- {
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- $connectionTime = Cache::get($cacheKey);
-
- if ($connectionTime === null) {
- return null;
- }
-
- return time() - $connectionTime;
- }
-
- /**
- * Refresh a multiplexed connection by closing and re-establishing it
- */
- public static function refreshMultiplexedConnection(Server $server): bool
- {
- // Close existing connection
- self::removeMuxFile($server);
-
- // Establish new connection
- return self::establishNewMultiplexedConnection($server);
- }
-
- /**
- * Store connection metadata when a new connection is established
- */
private static function storeConnectionMetadata(Server $server): void
{
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
+ Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
}
- /**
- * Clear connection metadata from cache
- */
private static function clearConnectionMetadata(Server $server): void
{
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- Cache::forget($cacheKey);
+ Cache::forget("ssh_mux_connection_time_{$server->uuid}");
}
}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 6b57d8f5f..5e5405a7a 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2,13 +2,14 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
-use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
+use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -16,10 +17,11 @@ use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
-use App\Models\Service;
+use App\Rules\DockerImageFormat;
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;
@@ -152,7 +154,7 @@ class ApplicationsController extends Controller
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -216,7 +218,7 @@ class ApplicationsController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -229,6 +231,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.'],
],
)
),
@@ -320,7 +323,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -381,7 +384,7 @@ class ApplicationsController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -394,6 +397,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.'],
],
)
),
@@ -485,7 +489,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -546,7 +550,7 @@ class ApplicationsController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -559,6 +563,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.'],
],
)
),
@@ -646,7 +651,7 @@ class ApplicationsController extends Controller
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -893,105 +898,6 @@ class ApplicationsController extends Controller
return $this->create_application($request, 'dockerimage');
}
- /**
- * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
- */
- #[OA\Post(
- summary: 'Create (Docker Compose)',
- description: 'Deprecated: Use POST /api/v1/services instead.',
- path: '/applications/dockercompose',
- operationId: 'create-dockercompose-application',
- deprecated: true,
- security: [
- ['bearerAuth' => []],
- ],
- tags: ['Applications'],
- requestBody: new OA\RequestBody(
- description: 'Application object that needs to be created.',
- required: true,
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
- properties: [
- 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
- 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
- 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
- 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
- 'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
- 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
- 'name' => ['type' => 'string', 'description' => 'The application name.'],
- 'description' => ['type' => 'string', 'description' => 'The application description.'],
- 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
- 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
- '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.'],
- ],
- )
- ),
- ]
- ),
- responses: [
- new OA\Response(
- response: 201,
- description: 'Application created successfully.',
- content: new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'uuid' => ['type' => 'string'],
- ]
- )
- )
- ),
- new OA\Response(
- response: 401,
- ref: '#/components/responses/401',
- ),
- new OA\Response(
- response: 400,
- ref: '#/components/responses/400',
- ),
- new OA\Response(
- response: 409,
- description: 'Domain conflicts detected.',
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
- 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
- 'conflicts' => [
- 'type' => 'array',
- 'items' => new OA\Schema(
- type: 'object',
- properties: [
- 'domain' => ['type' => 'string', 'example' => 'example.com'],
- 'resource_name' => ['type' => 'string', 'example' => 'My Application'],
- 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
- 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
- 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
- ]
- ),
- ],
- ]
- )
- ),
- ]
- ),
- ]
- )]
- public function create_dockercompose_application(Request $request)
- {
- return $this->create_application($request, 'dockercompose');
- }
-
private function create_application(Request $request, $type)
{
$teamId = getTeamIdFromToken();
@@ -1002,10 +908,10 @@ class ApplicationsController extends Controller
$this->authorize('create', Application::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ 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 +960,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)) {
@@ -1073,6 +980,9 @@ class ApplicationsController extends Controller
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@@ -1150,14 +1060,14 @@ class ApplicationsController extends Controller
$request->offsetSet('name', generate_application_name($request->git_repository, $request->git_branch));
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$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 +1176,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)) {
@@ -1298,6 +1212,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1345,7 +1268,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first();
@@ -1384,7 +1307,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 +1421,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();
@@ -1524,6 +1451,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1573,7 +1509,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$privateKey = PrivateKey::whereTeamId($teamId)->where('uuid', $request->private_key_uuid)->first();
@@ -1584,7 +1520,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 +1630,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();
@@ -1720,6 +1660,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1742,7 +1691,7 @@ class ApplicationsController extends Controller
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->dockerfile)) {
@@ -1771,7 +1720,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';
@@ -1827,14 +1776,23 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
- 'docker_registry_image_name' => 'string|required',
- 'docker_registry_image_tag' => 'string',
+ 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
+ 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
@@ -1850,7 +1808,7 @@ class ApplicationsController extends Controller
$request->offsetSet('name', 'docker-image-'.new Cuid2);
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
// Process docker image name and tag using DockerImageParser
@@ -1883,7 +1841,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;
@@ -1937,93 +1895,19 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
- } elseif ($type === 'dockercompose') {
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
-
- $extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
- if (! empty($extraFields)) {
- foreach ($extraFields as $field) {
- $errors->add($field, 'This field is not allowed.');
- }
- }
-
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $errors,
- ], 422);
- }
- if (! $request->has('name')) {
- $request->offsetSet('name', 'service'.new Cuid2);
- }
- $validationRules = [
- 'docker_compose_raw' => 'string|required',
- ];
- $validationRules = array_merge(sharedDataApplications(), $validationRules);
- $validator = customApiValidator($request->all(), $validationRules);
-
- if ($validator->fails()) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $validator->errors(),
- ], 422);
- }
- $return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
- return $return;
- }
- if (! isBase64Encoded($request->docker_compose_raw)) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerComposeRaw = base64_decode($request->docker_compose_raw);
- if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerCompose = base64_decode($request->docker_compose_raw);
- $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
-
- $service = new Service;
- removeUnnecessaryFieldsFromRequest($request);
- $service->fill($request->all());
-
- $service->docker_compose_raw = $dockerComposeRaw;
- $service->environment_id = $environment->id;
- $service->server_id = $server->id;
- $service->destination_id = $destination->id;
- $service->destination_type = $destination->getMorphClass();
- if (isset($isContainerLabelEscapeEnabled)) {
- $service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
- }
- $service->save();
-
- $service->parse(isNew: true);
-
- // Apply service-specific application prerequisites
- applyServiceApplicationPrerequisites($service);
-
- if ($instantDeploy) {
- StartService::dispatch($service);
- }
-
- return response()->json(serializeApiResponse([
- 'uuid' => data_get($service, 'uuid'),
- 'domains' => data_get($service, 'domains'),
- ]))->setStatusCode(201);
}
return response()->json(['message' => 'Invalid type.'], 400);
@@ -2278,6 +2162,12 @@ class ApplicationsController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.application.deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
return response()->json([
'message' => 'Application deletion request queued.',
]);
@@ -2320,7 +2210,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -2380,7 +2270,7 @@ class ApplicationsController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -2389,6 +2279,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.'],
],
)
),
@@ -2460,7 +2351,7 @@ class ApplicationsController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -2474,7 +2365,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',
@@ -2510,7 +2401,7 @@ class ApplicationsController extends Controller
}
}
}
- if ($request->has('custom_nginx_configuration')) {
+ if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
if (! isBase64Encoded($request->custom_nginx_configuration)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2528,9 +2419,12 @@ class ApplicationsController extends Controller
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$return = $this->validateDataApplications($request, $server);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -2721,7 +2615,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 +2650,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);
}
@@ -2773,6 +2670,13 @@ class ApplicationsController extends Controller
}
$application->save();
+ auditLog('api.application.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@@ -2956,7 +2860,7 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
@@ -3025,6 +2929,14 @@ class ApplicationsController extends Controller
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3058,6 +2970,14 @@ class ApplicationsController extends Controller
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3157,7 +3077,7 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->route('uuid'))->first();
@@ -3284,6 +3204,12 @@ class ApplicationsController extends Controller
$returnedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.application.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_count' => $returnedEnvs->count(),
+ ]);
+
return response()->json($returnedEnvs)->setStatusCode(201);
}
@@ -3423,6 +3349,14 @@ class ApplicationsController extends Controller
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3448,6 +3382,14 @@ class ApplicationsController extends Controller
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3539,8 +3481,17 @@ class ApplicationsController extends Controller
'message' => 'Environment variable not found.',
], 404);
}
+ $envKey = $found_env->key;
+ $envUuid = $found_env->uuid;
$found_env->forceDelete();
+ auditLog('api.application.env_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json([
'message' => 'Environment variable deleted.',
]);
@@ -3652,6 +3603,15 @@ class ApplicationsController extends Controller
);
}
+ auditLog('api.application.deployed', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'force_rebuild' => $force,
+ 'instant_deploy' => $instant_deploy,
+ ]);
+
return response()->json(
[
'message' => 'Deployment request queued.',
@@ -3740,6 +3700,13 @@ class ApplicationsController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
+ auditLog('api.application.stopped', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Application stopping request queued.',
@@ -3830,6 +3797,13 @@ class ApplicationsController extends Controller
], 200);
}
+ auditLog('api.application.restarted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ ]);
+
return response()->json(
[
'message' => 'Restart request queued.',
@@ -4077,7 +4051,7 @@ class ApplicationsController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -4096,9 +4070,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
@@ -4198,6 +4172,15 @@ class ApplicationsController extends Controller
$storage->save();
+ auditLog('api.application.storage_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -4274,9 +4257,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -4361,6 +4344,9 @@ class ApplicationsController extends Controller
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
$storage = LocalFileVolume::create([
@@ -4373,6 +4359,15 @@ class ApplicationsController extends Controller
]);
}
+ auditLog('api.application.storage_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -4446,8 +4441,93 @@ class ApplicationsController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.application.storage_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
+
+ #[OA\Delete(
+ summary: 'Delete Preview Deployment',
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
+ path: '/applications/{uuid}/previews/{pull_request_id}',
+ operationId: 'delete-preview-deployment-by-pull-request-id',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'pull_request_id',
+ in: 'path',
+ description: 'Pull request ID of the preview to delete.',
+ required: true,
+ schema: new OA\Schema(type: 'integer')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_preview_by_pull_request_id(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('delete', $application);
+
+ $pullRequestIdRaw = $request->route('pull_request_id');
+ if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
+ return response()->json(['message' => 'Invalid pull_request_id.'], 422);
+ }
+ $pullRequestId = (int) $pullRequestIdRaw;
+
+ $preview = ApplicationPreview::where('application_id', $application->id)
+ ->where('pull_request_id', $pullRequestId)
+ ->first();
+
+ if (! $preview) {
+ return response()->json(['message' => 'Preview not found.'], 404);
+ }
+
+ $preview->delete();
+ CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+
+ auditLog('api.application.preview_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'pull_request_id' => $pullRequestId,
+ ]);
+
+ return response()->json(['message' => 'Preview deletion request queued.']);
+ }
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..d652f2ba1 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -244,7 +245,7 @@ class CloudProviderTokensController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -286,6 +287,13 @@ class CloudProviderTokensController extends Controller
'name' => $body['name'],
]);
+ auditLog('api.cloud_token.created', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudProviderToken->uuid,
+ 'cloud_token_name' => $cloudProviderToken->name,
+ 'provider' => $cloudProviderToken->provider,
+ ]);
+
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@@ -355,7 +363,7 @@ class CloudProviderTokensController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -389,6 +397,14 @@ class CloudProviderTokensController extends Controller
$token->update(array_intersect_key($body, array_flip($allowedFields)));
+ auditLog('api.cloud_token.updated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
+ ]);
+
return response()->json([
'uuid' => $token->uuid,
]);
@@ -464,8 +480,18 @@ class CloudProviderTokensController extends Controller
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
+ auditLog('api.cloud_token.deleted', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
return response()->json(['message' => 'Cloud provider token deleted.']);
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 44b66e57e..bceef4d39 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'],
@@ -297,6 +299,11 @@ class DatabasesController extends Controller
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
+ 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
+ 'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
+ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
+ 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
+ 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
],
),
)
@@ -326,7 +333,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();
@@ -334,7 +341,7 @@ class DatabasesController extends Controller
// this check if the request is a valid json
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -343,6 +350,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,11 +382,11 @@ 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',
- 'postgres_db' => 'string',
+ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@@ -405,22 +413,22 @@ 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',
+ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
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',
+ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
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_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
if ($request->has('redis_conf')) {
@@ -445,9 +453,9 @@ 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_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
if ($request->has('keydb_conf')) {
@@ -472,13 +480,13 @@ 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',
- 'mariadb_user' => 'string',
- 'mariadb_password' => 'string',
- 'mariadb_database' => 'string',
+ 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mariadb_conf')) {
if (! isBase64Encoded($request->mariadb_conf)) {
@@ -502,12 +510,12 @@ 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',
- 'mongo_initdb_root_password' => 'string',
- 'mongo_initdb_database' => 'string',
+ 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mongo_conf')) {
if (! isBase64Encoded($request->mongo_conf)) {
@@ -532,12 +540,12 @@ 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',
- 'mysql_user' => 'string',
- 'mysql_database' => 'string',
+ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
if ($request->has('mysql_conf')) {
@@ -562,9 +570,17 @@ class DatabasesController extends Controller
}
break;
}
+ $allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
+ $healthCheckValidator = customApiValidator($request->all(), [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer|min:1',
+ 'health_check_timeout' => 'integer|min:1',
+ 'health_check_retries' => 'integer|min:1',
+ 'health_check_start_period' => 'integer|min:0',
+ ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
+ if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
@@ -593,6 +609,14 @@ class DatabasesController extends Controller
StopDatabaseProxy::dispatch($database);
}
+ auditLog('api.database.updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'message' => 'Database updated.',
]);
@@ -636,10 +660,11 @@ class DatabasesController extends Controller
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'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'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
+ 'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
)
@@ -676,7 +701,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)) {
@@ -685,7 +710,7 @@ class DatabasesController extends Controller
// Validate incoming request is valid JSON
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -699,10 +724,11 @@ class DatabasesController extends Controller
'databases_to_backup' => 'string|nullable',
'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_max_storage_locally' => 'numeric|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',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
+ 'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -742,7 +768,7 @@ class DatabasesController extends Controller
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -769,7 +795,7 @@ class DatabasesController extends Controller
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -821,6 +847,15 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'frequency' => $backupConfig->frequency,
+ 'save_s3' => (bool) $backupConfig->save_s3,
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -873,10 +908,11 @@ class DatabasesController extends Controller
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'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'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', '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 +942,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)) {
@@ -914,7 +950,7 @@ class DatabasesController extends Controller
}
// this check if the request is a valid json
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -924,13 +960,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_max_storage_locally' => 'numeric|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',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
+ 'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
return response()->json([
@@ -957,6 +994,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.',
@@ -964,7 +1012,7 @@ class DatabasesController extends Controller
], 422);
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -997,7 +1045,7 @@ class DatabasesController extends Controller
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -1027,6 +1075,14 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_updated', [
+ 'team_id' => $teamId,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'database_id' => $backupConfig->database_id,
+ 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1067,6 +1123,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 +1191,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 +1258,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 +1326,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 +1394,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 +1465,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 +1536,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 +1604,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 +1643,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)) {
@@ -1590,7 +1654,7 @@ class DatabasesController extends Controller
$this->authorize('create', StandalonePostgresql::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -1669,6 +1733,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,11 +1760,11 @@ 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',
- 'postgres_db' => 'string',
+ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@@ -1739,7 +1804,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1752,12 +1817,25 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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',
+ 'mariadb_conf' => 'string',
+ 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -1794,7 +1872,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1808,14 +1886,24 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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',
- 'mysql_user' => 'string',
- 'mysql_database' => 'string',
+ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1853,7 +1941,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1867,11 +1955,21 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1909,7 +2007,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1923,11 +2021,21 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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',
+ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1946,7 +2054,7 @@ class DatabasesController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->all());
+ $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1955,9 +2063,9 @@ 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_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1995,7 +2103,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2009,12 +2117,22 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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',
+ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -2031,7 +2149,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2045,14 +2163,24 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
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',
- 'mongo_initdb_root_password' => 'string',
- 'mongo_initdb_database' => 'string',
+ 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -2089,7 +2217,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, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2103,6 +2231,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2187,6 +2325,13 @@ class DatabasesController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.database.deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2299,13 +2444,21 @@ class DatabasesController extends Controller
$backup->delete();
DB::commit();
+ auditLog('api.database.backup_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'delete_s3' => $deleteS3,
+ 'executions_deleted' => $executions->count(),
+ ]);
+
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup.'], 500);
}
}
@@ -2421,11 +2574,19 @@ class DatabasesController extends Controller
$execution->delete();
+ auditLog('api.database.backup_execution_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'execution_uuid' => $request->execution_uuid,
+ 'delete_s3' => $deleteS3,
+ ]);
+
return response()->json([
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
- return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup execution.'], 500);
}
}
@@ -2603,6 +2764,13 @@ class DatabasesController extends Controller
}
StartDatabase::dispatch($database);
+ auditLog('api.database.started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2694,6 +2862,14 @@ class DatabasesController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
+ auditLog('api.database.stopped', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2771,6 +2947,13 @@ class DatabasesController extends Controller
RestartDatabase::dispatch($database);
+ auditLog('api.database.restarted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -2987,6 +3170,13 @@ class DatabasesController extends Controller
}
$env->save();
+ auditLog('api.database.env_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3115,6 +3305,12 @@ class DatabasesController extends Controller
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
+ auditLog('api.database.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3236,6 +3432,13 @@ class DatabasesController extends Controller
'comment' => $request->comment ?? null,
]);
+ auditLog('api.database.env_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3321,8 +3524,17 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.database.env_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3467,9 +3679,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -3554,6 +3766,9 @@ class DatabasesController extends Controller
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
$storage = LocalFileVolume::create([
@@ -3566,6 +3781,15 @@ class DatabasesController extends Controller
]);
}
+ auditLog('api.database.storage_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -3646,7 +3870,7 @@ class DatabasesController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -3662,9 +3886,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
@@ -3764,6 +3988,15 @@ class DatabasesController extends Controller
$storage->save();
+ auditLog('api.database.storage_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -3837,8 +4070,18 @@ class DatabasesController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.database.storage_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 85d532f62..c93731d68 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
@@ -278,6 +281,14 @@ class DeployController extends Controller
}
}
+ auditLog('api.deployment.cancelled', [
+ 'team_id' => $teamId,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'application_id' => $application?->id,
+ 'application_uuid' => $application?->uuid,
+ 'server_id' => $deployment->server_id,
+ ]);
+
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@@ -304,6 +315,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 +367,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 +377,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 +405,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 +493,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 +505,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 +518,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];
@@ -494,23 +526,35 @@ class DeployController extends Controller
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
+ auditLog('api.deployment.triggered', [
+ 'resource_type' => 'application',
+ 'application_uuid' => $resource->uuid,
+ 'application_name' => $resource->name,
+ 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'force_rebuild' => $force,
+ 'pull_request_id' => $pr,
+ ]);
}
break;
case Service::class:
// 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);
$message = "Service {$resource->name} started. It could take a while, be patient.";
+ auditLog('api.service.deployed', [
+ 'service_uuid' => $resource->uuid,
+ 'service_name' => $resource->name,
+ ]);
break;
default:
// 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);
@@ -519,12 +563,45 @@ class DeployController extends Controller
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
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..651969b97 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',
@@ -268,6 +271,12 @@ class GithubController extends Controller
$githubApp = GithubApp::create($payload);
+ auditLog('api.github_app.created', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ ]);
+
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@@ -370,7 +379,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 +481,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 +596,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';
@@ -647,11 +656,18 @@ class GithubController extends Controller
// Update the GitHub app
$githubApp->update($payload);
+ auditLog('api.github_app.updated', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
+ ]);
+
return response()->json([
'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);
@@ -731,12 +747,20 @@ class GithubController extends Controller
], 409);
}
+ $deletedUuid = $githubApp->uuid;
+ $deletedName = $githubApp->name;
$githubApp->delete();
+ auditLog('api.github_app.deleted', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $deletedUuid,
+ 'github_app_name' => $deletedName,
+ ]);
+
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/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index ed91b4475..2f35ba576 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@@ -12,6 +13,7 @@ use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -121,7 +123,7 @@ class HetznerController extends Controller
return response()->json($locations);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
}
}
@@ -242,7 +244,7 @@ class HetznerController extends Controller
return response()->json($serverTypes);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
}
}
@@ -354,7 +356,7 @@ class HetznerController extends Controller
return response()->json(array_values($filtered));
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
}
}
@@ -450,7 +452,7 @@ class HetznerController extends Controller
return response()->json($sshKeys);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
}
}
@@ -550,7 +552,7 @@ class HetznerController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -717,9 +719,17 @@ class HetznerController extends Controller
// Validate server if requested
if ($request->instant_validate) {
- \App\Actions\Server\ValidateServer::dispatch($server);
+ ValidateServer::dispatch($server);
}
+ auditLog('api.hetzner_server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
@@ -733,7 +743,7 @@ class HetznerController extends Controller
return $response;
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
}
}
}
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 8f2ba25c8..f17a4e46b 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ class OtherController extends Controller
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
+ auditLog('api.instance.enabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API enabled.'], 200);
}
@@ -137,21 +141,141 @@ class OtherController extends Controller
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
+ auditLog('api.instance.disabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API disabled.'], 200);
}
+ #[OA\Post(
+ summary: 'Enable MCP Server',
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/enable',
+ operationId: 'enable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server enabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to enable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function enable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => true]);
+
+ auditLog('api.mcp.enabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server enabled.'], 200);
+ }
+
+ #[OA\Post(
+ summary: 'Disable MCP Server',
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/disable',
+ operationId: 'disable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server disabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to disable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function disable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => false]);
+
+ auditLog('api.mcp.disabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server disabled.'], 200);
+ }
+
public function feedback(Request $request)
{
- $content = $request->input('content');
+ $data = $request->validate([
+ 'content' => ['required', 'string', 'min:10', 'max:2000'],
+ ]);
+
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
- Http::post($webhook_url, [
- 'content' => $content,
+ Http::timeout(5)->post($webhook_url, [
+ 'content' => $data['content'],
+ 'allowed_mentions' => ['parse' => []],
]);
}
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index da553a68c..0e5f6e93b 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(), [
@@ -263,6 +264,12 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
+ auditLog('api.project.created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -347,7 +354,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(), [
@@ -381,6 +388,13 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
+ auditLog('api.project.updated', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -459,8 +473,16 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
+ $projectUuid = $project->uuid;
+ $projectName = $project->name;
$project->delete();
+ auditLog('api.project.deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $projectUuid,
+ 'project_name' => $projectName,
+ ]);
+
return response()->json(['message' => 'Project deleted.']);
}
@@ -600,7 +622,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(), [
@@ -640,6 +662,13 @@ class ProjectController extends Controller
'name' => $request->name,
]);
+ auditLog('api.project.environment_created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'environment_name' => $environment->name,
+ ]);
+
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@@ -722,8 +751,17 @@ class ProjectController extends Controller
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
+ $envUuid = $environment->uuid;
+ $envName = $environment->name;
$environment->delete();
+ auditLog('api.project.environment_deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $envUuid,
+ 'environment_name' => $envName,
+ ]);
+
return response()->json(['message' => 'Environment deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
index 6245dc2ec..d7b109918 100644
--- a/app/Http/Controllers/Api/ScheduledTasksController.php
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -33,7 +34,7 @@ class ScheduledTasksController extends Controller
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ class ScheduledTasksController extends Controller
return response()->json($tasks);
}
- private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -105,15 +106,23 @@ class ScheduledTasksController extends Controller
$task->save();
+ auditLog('api.scheduled_task.created', [
+ 'team_id' => $teamId,
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json($this->removeSensitiveData($task), 201);
}
- private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -161,22 +170,43 @@ class ScheduledTasksController extends Controller
$task->update($request->only($allowedFields));
+ auditLog('api.scheduled_task.updated', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json($this->removeSensitiveData($task), 200);
}
- private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
- $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
- if (! $deleted) {
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
+ $taskUuid = $task->uuid;
+ $taskName = $task->name;
+ $task->delete();
+
+ auditLog('api.scheduled_task.deleted', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $taskUuid,
+ 'task_name' => $taskName,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json(['message' => 'Scheduled task deleted.']);
}
- private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -238,7 +268,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,7 +347,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -404,7 +434,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -474,7 +504,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -542,7 +572,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -601,7 +631,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -680,7 +710,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -767,7 +797,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -837,7 +867,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -905,7 +935,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index e7b36cb9a..e59c40866 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(), [
@@ -231,6 +232,13 @@ class SecurityController extends Controller
'private_key' => $request->private_key,
]);
+ auditLog('api.private_key.created', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $key->uuid,
+ 'private_key_name' => $key->name,
+ 'fingerprint' => $fingerPrint,
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@@ -300,7 +308,7 @@ class SecurityController extends Controller
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -330,7 +338,14 @@ class SecurityController extends Controller
'message' => 'Private Key not found.',
], 404);
}
- $foundKey->update($request->all());
+ $foundKey->update($request->only($allowedFields));
+
+ auditLog('api.private_key.updated', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $foundKey->uuid,
+ 'private_key_name' => $foundKey->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
@@ -414,8 +429,16 @@ class SecurityController extends Controller
], 422);
}
+ $keyUuid = $key->uuid;
+ $keyName = $key->name;
$key->forceDelete();
+ auditLog('api.private_key.deleted', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $keyUuid,
+ 'private_key_name' => $keyName,
+ ]);
+
return response()->json([
'message' => 'Private Key deleted.',
]);
diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php
new file mode 100644
index 000000000..df5c60d40
--- /dev/null
+++ b/app/Http/Controllers/Api/SentinelController.php
@@ -0,0 +1,167 @@
+header('Authorization');
+ if (! $token) {
+ auditLogWebhookFailure('sentinel', 'token_missing');
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+ $naked_token = str_replace('Bearer ', '', $token);
+ try {
+ $decrypted = decrypt($naked_token);
+ $decrypted_token = json_decode($decrypted, true);
+ } catch (Exception $e) {
+ auditLogWebhookFailure('sentinel', 'decrypt_failed');
+
+ return response()->json(['message' => 'Invalid token'], 401);
+ }
+ $server_uuid = data_get($decrypted_token, 'server_uuid');
+ if (! $server_uuid) {
+ auditLogWebhookFailure('sentinel', 'invalid_token_payload');
+
+ return response()->json(['message' => 'Invalid token'], 401);
+ }
+ $server = Server::where('uuid', $server_uuid)->first();
+ if (! $server) {
+ auditLogWebhookFailure('sentinel', 'server_not_found', [
+ 'server_uuid' => $server_uuid,
+ ]);
+
+ return response()->json(['message' => 'Server not found'], 404);
+ }
+
+ if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
+ auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+
+ if ($server->isFunctional() === false) {
+ auditLogWebhookFailure('sentinel', 'server_not_functional', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Server is not functional'], 401);
+ }
+
+ if ($server->settings->sentinel_token !== $naked_token) {
+ auditLogWebhookFailure('sentinel', 'token_mismatch', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+ $validator = Validator::make($request->all(), [
+ 'containers' => ['present', 'array'],
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json(serializeApiResponse([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ]), 422);
+ }
+
+ $data = $request->all();
+
+ // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
+ $server->sentinelHeartbeat();
+
+ if ($this->shouldDispatchUpdate($server, $data)) {
+ PushServerUpdateJob::dispatch($server, $data);
+ }
+
+ auditLog('sentinel.metrics_pushed', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'ok'], 200);
+ }
+
+ /**
+ * Decide whether PushServerUpdateJob should be dispatched for this push.
+ *
+ * Dispatches when: first push (no cached hash), the container state changed,
+ * or the force window elapsed.
+ */
+ private function shouldDispatchUpdate(Server $server, array $data): bool
+ {
+ $hash = $this->containerStateHash($data);
+ $hashKey = "sentinel:push-hash:{$server->id}";
+ $forceKey = "sentinel:push-force:{$server->id}";
+ $lockKey = "sentinel:push-lock:{$server->id}";
+
+ try {
+ return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
+ $cachedHash = Cache::get($hashKey);
+ $forceActive = Cache::has($forceKey);
+
+ $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
+
+ if ($shouldDispatch) {
+ // Day-long TTL bounds memory if a server stops pushing entirely.
+ Cache::put($hashKey, $hash, now()->addDay());
+ Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
+ }
+
+ return $shouldDispatch;
+ });
+ } catch (LockTimeoutException) {
+ return false;
+ }
+ }
+
+ /**
+ * Build a stable hash of container state.
+ *
+ * Covers [name, state] only — metrics, filesystem_usage_root, and
+ * health_status are excluded on purpose. Disk % churns constantly, and
+ * health checks can flap between starting/healthy/unhealthy while the
+ * container lifecycle state remains unchanged. Both would otherwise defeat
+ * the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
+ * The force window still refreshes full state periodically. Sorted by name
+ * so container ordering from Sentinel does not affect the hash.
+ */
+ private function containerStateHash(array $data): string
+ {
+ $containers = collect(data_get($data, 'containers', []))
+ ->map(fn ($c) => [
+ 'name' => data_get($c, 'name'),
+ 'state' => data_get($c, 'state'),
+ ])
+ ->sortBy('name')
+ ->values()
+ ->all();
+
+ return hash('xxh128', json_encode($containers));
+ }
+}
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 2ef95ce8b..6c3b2da00 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -13,6 +13,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@@ -477,7 +478,7 @@ class ServersController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -564,6 +565,14 @@ class ServersController extends Controller
ValidateServer::dispatch($server);
}
+ auditLog('api.server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'ip' => $server->ip,
+ 'is_build_server' => (bool) $request->is_build_server,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -598,6 +607,12 @@ 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.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@@ -634,7 +649,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', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -642,7 +657,7 @@ class ServersController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -655,6 +670,12 @@ 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',
+ 'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -691,10 +712,30 @@ 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', 'connection_timeout']);
+ if (! empty($advancedSettings)) {
+ $server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
+ }
+
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
+ auditLog('api.server.updated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -784,6 +825,9 @@ class ServersController extends Controller
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -793,6 +837,14 @@ class ServersController extends Controller
$server->team_id
);
+ auditLog('api.server.deleted', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $deletedUuid,
+ 'server_name' => $deletedName,
+ 'ip' => $deletedIp,
+ 'force' => $force,
+ ]);
+
return response()->json(['message' => 'Server deleted.']);
}
@@ -858,6 +910,12 @@ class ServersController extends Controller
}
ValidateServer::dispatch($server);
+ auditLog('api.server.validated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
+
return response()->json(['message' => 'Validation started.'], 201);
}
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index ca565ece0..11a23d46c 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;
@@ -220,7 +221,7 @@ class ServicesController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
+ 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@@ -302,7 +303,7 @@ class ServicesController extends Controller
$this->authorize('create', Service::class);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validationRules = [
@@ -485,6 +486,14 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => $oneClickServiceName ?? null,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -649,6 +658,14 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => 'docker_compose',
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -791,6 +808,12 @@ class ServicesController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.service.deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json([
'message' => 'Service deletion request queued.',
]);
@@ -842,7 +865,7 @@ class ServicesController extends Controller
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
+ 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@@ -925,7 +948,7 @@ class ServicesController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -1045,6 +1068,13 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -1254,6 +1284,13 @@ class ServicesController extends Controller
}
$env->save();
+ auditLog('api.service.env_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1383,6 +1420,12 @@ class ServicesController extends Controller
$updatedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.service.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -1505,6 +1548,13 @@ class ServicesController extends Controller
'comment' => $request->comment ?? null,
]);
+ auditLog('api.service.env_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1590,8 +1640,17 @@ class ServicesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.service.env_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -1667,6 +1726,12 @@ class ServicesController extends Controller
}
StartService::dispatch($service);
+ auditLog('api.service.deployed', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json(
[
'message' => 'Service starting request queued.',
@@ -1758,6 +1823,13 @@ class ServicesController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
+ auditLog('api.service.stopped', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Service stopping request queued.',
@@ -1845,6 +1917,13 @@ class ServicesController extends Controller
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
+ auditLog('api.service.restarted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'pull_latest' => $pullLatest,
+ ]);
+
return response()->json(
[
'message' => 'Service restarting request queued.',
@@ -2015,9 +2094,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -2110,6 +2189,9 @@ class ServicesController extends Controller
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
+
+ validateShellSafePath($mountPath, 'file storage path');
+
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
$storage = LocalFileVolume::create([
@@ -2122,6 +2204,15 @@ class ServicesController extends Controller
]);
}
+ auditLog('api.service.storage_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -2221,9 +2312,9 @@ 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',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
@@ -2350,6 +2441,15 @@ class ServicesController extends Controller
$storage->save();
+ auditLog('api.service.storage_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -2450,8 +2550,18 @@ class ServicesController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.service.storage_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
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..3090538c3 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -6,8 +6,9 @@ use App\Events\TestEvent;
use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
+use Illuminate\Auth\Events\Verified;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
@@ -39,9 +40,29 @@ class Controller extends BaseController
return view('auth.verify-email');
}
- public function email_verify(EmailVerificationRequest $request)
+ public function email_verify(Request $request)
{
- $request->fulfill();
+ if (! $request->hasValidSignature()) {
+ abort(403);
+ }
+
+ $user = auth()->user();
+ if (! $user) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
+ abort(403);
+ }
+
+ if (! $user->hasVerifiedEmail()) {
+ $user->markEmailAsVerified();
+ event(new Verified($user));
+ }
return redirect(RouteServiceProvider::HOME);
}
@@ -78,27 +99,50 @@ class Controller extends BaseController
{
$token = request()->get('token');
if ($token) {
- $decrypted = Crypt::decryptString($token);
- $email = str($decrypted)->before('@@@');
- $password = str($decrypted)->after('@@@');
+ try {
+ $decrypted = Crypt::decryptString($token);
+ } catch (DecryptException) {
+ return redirect()->route('login')->with('error', 'Invalid credentials.');
+ }
+
+ if (! str_contains($decrypted, '@@@')) {
+ return redirect()->route('login')->with('error', 'Invalid credentials.');
+ }
+
+ $payload = explode('@@@', $decrypted, 3);
+ if (count($payload) === 3) {
+ [$email, $invitationUuid, $password] = $payload;
+ } else {
+ [$email, $password] = $payload;
+ $invitationUuid = null;
+ }
+
+ $email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
+
+ $invitation = TeamInvitation::query()
+ ->where('email', $email)
+ ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
+ ->where('link', request()->fullUrl())
+ ->first();
+ if (! $invitation || ! $invitation->isValid()) {
+ return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
+ }
+
if (Hash::check($password, $user->password)) {
- $invitation = TeamInvitation::whereEmail($email);
- if ($invitation->exists()) {
- $team = $invitation->first()->team;
- $user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
- $invitation->delete();
- } else {
- $team = $user->teams()->first();
- }
- if (is_null(data_get($user, 'email_verified_at'))) {
- $user->email_verified_at = now();
- $user->save();
+ $team = $invitation->team;
+ if (! $user->teams()->where('team_id', $team->id)->exists()) {
+ $user->teams()->attach($team->id, ['role' => $invitation->role]);
}
+ $invitation->delete();
+
Auth::login($user);
+ $user->forceFill([
+ 'password' => Hash::make(Str::random(64)),
+ ])->save();
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
@@ -108,9 +152,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 +185,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/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 3a3f18c9c..4038fe63e 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -19,7 +19,12 @@ class OauthController extends Controller
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ class OauthController extends Controller
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 93847589a..6c3dda402 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -11,6 +11,27 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends BaseController
{
+ private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
+
+ private const ALLOWED_EXTENSIONS = [
+ 'sql',
+ 'sql.gz',
+ 'gz',
+ 'zip',
+ 'tar',
+ 'tar.gz',
+ 'tgz',
+ 'dump',
+ 'bak',
+ 'bson',
+ 'bson.gz',
+ 'archive',
+ 'archive.gz',
+ 'bz2',
+ 'xz',
+ 'dmp',
+ ];
+
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@@ -18,6 +39,22 @@ class UploadController extends BaseController
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
+
+ $chunk = $request->file('file');
+ $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
+ if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
+ return response()->json([
+ 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
+ ], 422);
+ }
+
+ $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
+ if ($declaredTotalSize > self::MAX_BYTES) {
+ return response()->json([
+ 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
+ ], 422);
+ }
+
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
@@ -40,29 +77,20 @@ class UploadController extends BaseController
'status' => true,
]);
}
- // protected function saveFileToS3($file)
- // {
- // $fileName = $this->createFilename($file);
- // $disk = Storage::disk('s3');
- // // It's better to use streaming Streaming (laravel 5.4+)
- // $disk->putFileAs('photos', $file, $fileName);
-
- // // for older laravel
- // // $disk->put($fileName, file_get_contents($file), 'public');
- // $mime = str_replace('/', '-', $file->getMimeType());
-
- // // We need to delete the file when uploaded to s3
- // unlink($file->getPathname());
-
- // return response()->json([
- // 'path' => $disk->url($fileName),
- // 'name' => $fileName,
- // 'mime_type' => $mime
- // ]);
- // }
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
+ $originalName = $file->getClientOriginalName();
+ $size = $file->getSize();
+
+ if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
+ @unlink($file->getPathname());
+
+ return response()->json([
+ 'error' => 'Uploaded file failed validation.',
+ ], 422);
+ }
+
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
@@ -73,13 +101,30 @@ class UploadController extends BaseController
]);
}
- protected function createFilename(UploadedFile $file)
+ private static function hasAllowedExtension(string $name): bool
{
- $extension = $file->getClientOriginalExtension();
- $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
+ $lower = strtolower($name);
+ $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
+ usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
- $filename .= '_'.md5(time()).'.'.$extension;
+ foreach ($suffixes as $suffix) {
+ if (! str_ends_with($lower, $suffix)) {
+ continue;
+ }
- return $filename;
+ $stem = substr($lower, 0, -strlen($suffix));
+ if ($stem !== '' && ! str_ends_with($stem, '.')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private static function formatMaxSize(): string
+ {
+ return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
}
}
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index 183186711..d37ba7cee 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +14,9 @@ use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -31,6 +36,16 @@ class Bitbucket extends Controller
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
+ // Bitbucket webhooks ship up to 5 commits per change. Larger pushes
+ // are evaluated only on the visible 5.
+ $skip_deploy_commits = self::shouldSkipDeploy(
+ collect(data_get($payload, 'push.changes', []))
+ ->flatMap(fn ($change) => data_get($change, 'commits', []))
+ ->pluck('message')
+ ->filter()
+ ->values()
+ ->all()
+ );
if (! $branch) {
return response([
@@ -45,10 +60,18 @@ class Bitbucket extends Controller
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
+ $pull_request_title = data_get($payload, 'pullrequest.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
- $applications = $applications->where('git_branch', $branch)->get();
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response([
+ 'status' => 'failed',
+ 'message' => 'Nothing to do. Invalid repository.',
+ ]);
+ }
+ $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([
'status' => 'failed',
@@ -57,16 +80,41 @@ class Bitbucket extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$payload = $request->getContent();
- [$algo, $hash] = explode('=', $x_bitbucket_token, 2);
- $payloadHash = hash_hmac($algo, $payload, $webhook_secret);
- if (! hash_equals($hash, $payloadHash) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ $parts = explode('=', $x_bitbucket_token, 2);
+ if (count($parts) !== 2 || $parts[0] !== 'sha256') {
+ auditLogWebhookFailure('bitbucket', 'malformed_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
+ $hash = $parts[1];
+ $payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
+ if (! hash_equals($hash, $payloadHash) && ! isDev()) {
+ auditLogWebhookFailure('bitbucket', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -82,6 +130,17 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -99,6 +158,15 @@ class Bitbucket extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'bitbucket',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => $commit,
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -115,6 +183,15 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
new file mode 100644
index 000000000..69695e99b
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
@@ -0,0 +1,55 @@
+ $messages
+ */
+ public static function shouldSkipDeploy(array $messages): bool
+ {
+ $messages = array_values(array_filter($messages, fn ($m) => filled($m)));
+
+ if (empty($messages)) {
+ return false;
+ }
+
+ foreach ($messages as $message) {
+ $lower = strtolower((string) $message);
+ if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if at least one non-empty message contains [skip cd] or
+ * [skip ci]. Used for PR/MR title + latest-commit signals where any one
+ * marker should trigger the skip.
+ *
+ * @param array $messages
+ */
+ public static function shouldSkipDeployAny(array $messages): bool
+ {
+ foreach ($messages as $message) {
+ if (! filled($message)) {
+ continue;
+ }
+ $lower = strtolower((string) $message);
+ if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php
new file mode 100644
index 000000000..0463790eb
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php
@@ -0,0 +1,108 @@
+normalizeManualWebhookRepositoryPath($fullName);
+ }
+
+ /**
+ * @return Collection
+ */
+ protected function manualWebhookApplications(Builder $query, string $fullName): Collection
+ {
+ return $query->get()
+ ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
+ ->values();
+ }
+
+ protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
+ {
+ $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
+
+ if ($repositoryPath === null) {
+ return false;
+ }
+
+ // Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
+ // case-insensitively, so compare the canonical paths case-insensitively.
+ return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
+ }
+
+ /**
+ * @return array{status: string, message: string}
+ */
+ protected function unauthenticatedManualWebhookFailurePayload(): array
+ {
+ return [
+ 'status' => 'failed',
+ 'message' => 'Invalid signature.',
+ ];
+ }
+
+ protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
+ {
+ if (! is_string($gitRepository)) {
+ return null;
+ }
+
+ $gitRepository = trim($gitRepository);
+
+ if ($gitRepository === '') {
+ return null;
+ }
+
+ $path = null;
+ $parts = parse_url($gitRepository);
+
+ if (is_array($parts) && isset($parts['scheme'])) {
+ $path = data_get($parts, 'path');
+ } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
+ $path = Str::after($gitRepository, ':');
+ // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
+ // Strip the leading numeric port segment so the path matches the webhook
+ // payload's owner/repo, consistent with convertGitUrl() in shared.php.
+ $path = preg_replace('#^\d+/#', '', $path) ?? $path;
+ } else {
+ $path = $gitRepository;
+ }
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ return $this->normalizeManualWebhookRepositoryPath($path);
+ }
+
+ protected function normalizeManualWebhookRepositoryPath(string $path): string
+ {
+ $path = trim($path);
+ $path = strtok($path, '?#') ?: $path;
+ $path = trim($path, '/');
+ $path = preg_replace('/\.git\z/i', '', $path) ?? $path;
+
+ return $path;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index a9d65eae6..be064e380 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +15,9 @@ use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -40,40 +45,60 @@ class Gitea extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response('Nothing to do. Invalid repository.');
+ }
+ $applications = Application::query();
if ($x_gitea_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_gitea_event === 'pull_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ auditLogWebhookFailure('gitea', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -91,6 +116,17 @@ class Gitea extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -108,6 +144,15 @@ class Gitea extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitea',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -140,6 +185,15 @@ class Gitea extends Controller
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index fe49369ea..40c5cbdf0 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -3,19 +3,27 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
+use Illuminate\Http\Exceptions\HttpResponseException;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Github extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -43,17 +51,20 @@ class Github extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
+ $is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -61,15 +72,19 @@ class Github extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response('Nothing to do. Invalid repository.');
+ }
+ $applications = Application::query();
if ($x_github_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_github_event === 'pull_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@@ -81,13 +96,26 @@ class Github extends Controller
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('github', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -105,6 +133,17 @@ class Github extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -122,6 +161,15 @@ class Github extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -171,11 +219,13 @@ class Github extends Controller
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -215,6 +265,13 @@ class Github extends Controller
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'mode' => 'app',
+ 'github_app_id' => $github_app->id,
+ 'github_app_name' => $github_app->name,
+ 'installation_target_id' => $x_github_hook_installation_target_id,
+ ]);
+
return response('Invalid signature.');
}
}
@@ -237,17 +294,20 @@ class Github extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
+ $is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -291,6 +351,17 @@ class Github extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -302,6 +373,17 @@ class Github extends Controller
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
+ if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'app',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'github_app_id' => $github_app->id,
+ ]);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -351,11 +433,13 @@ class Github extends Controller
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -373,55 +457,203 @@ class Github extends Controller
}
}
+ /**
+ * Determine whether a pull_request webhook payload originates from a fork.
+ *
+ * GitHub's `author_association` is not a reliable trust signal (it grants
+ * CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
+ * detection is gated on whether the PR crosses repository boundaries.
+ *
+ * The repository id comparison is the canonical signal; the `head.repo.fork`
+ * flag and a case-insensitive full_name comparison are fallbacks for payloads
+ * where the ids are unavailable (e.g. a deleted head repository).
+ */
+ private function isForkPullRequest(mixed $payload): bool
+ {
+ $headRepoId = data_get($payload, 'pull_request.head.repo.id');
+ $baseRepoId = data_get($payload, 'pull_request.base.repo.id');
+
+ if ($headRepoId !== null && $baseRepoId !== null) {
+ return (string) $headRepoId !== (string) $baseRepoId;
+ }
+
+ if (data_get($payload, 'pull_request.head.repo.fork') === true) {
+ return true;
+ }
+
+ $headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
+ $baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
+
+ if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
+ return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
+ }
+
+ return false;
+ }
+
public function redirect(Request $request)
{
- try {
- $code = $request->get('code');
- $state = $request->get('state');
- $github_app = GithubApp::where('uuid', $state)->firstOrFail();
- $api_url = data_get($github_app, 'api_url');
- $data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
- $id = data_get($data, 'id');
- $slug = data_get($data, 'slug');
- $client_id = data_get($data, 'client_id');
- $client_secret = data_get($data, 'client_secret');
- $private_key = data_get($data, 'pem');
- $webhook_secret = data_get($data, 'webhook_secret');
- $private_key = PrivateKey::create([
- 'name' => "github-app-{$slug}",
- 'private_key' => $private_key,
- 'team_id' => $github_app->team_id,
- 'is_git_related' => true,
- ]);
- $github_app->name = $slug;
- $github_app->app_id = $id;
- $github_app->client_id = $client_id;
- $github_app->client_secret = $client_secret;
- $github_app->webhook_secret = $webhook_secret;
- $github_app->private_key_id = $private_key->id;
- $github_app->save();
+ $code = (string) $request->query('code', '');
+ abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
- return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
- } catch (Exception $e) {
- return handleError($e);
- }
+ $github_app = $this->consumeGithubAppSetupState(
+ request: $request,
+ state: (string) $request->query('state', ''),
+ action: 'manifest',
+ );
+
+ abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
+
+ $api_url = data_get($github_app, 'api_url');
+ $data = Http::withBody(null)
+ ->accept('application/vnd.github+json')
+ ->timeout(10)
+ ->connectTimeout(5)
+ ->post("$api_url/app-manifests/$code/conversions")
+ ->throw()
+ ->json();
+
+ $id = data_get($data, 'id');
+ $slug = data_get($data, 'slug');
+ $client_id = data_get($data, 'client_id');
+ $client_secret = data_get($data, 'client_secret');
+ $private_key = data_get($data, 'pem');
+ $webhook_secret = data_get($data, 'webhook_secret');
+
+ abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
+
+ $private_key = PrivateKey::create([
+ 'name' => "github-app-{$slug}",
+ 'private_key' => $private_key,
+ 'team_id' => $github_app->team_id,
+ 'is_git_related' => true,
+ ]);
+ $github_app->name = $slug;
+ $github_app->app_id = $id;
+ $github_app->client_id = $client_id;
+ $github_app->client_secret = $client_secret;
+ $github_app->webhook_secret = $webhook_secret;
+ $github_app->private_key_id = $private_key->id;
+ $github_app->save();
+
+ return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
- try {
- $installation_id = $request->get('installation_id');
- $source = $request->get('source');
- $setup_action = $request->get('setup_action');
- $github_app = GithubApp::where('uuid', $source)->firstOrFail();
- if ($setup_action === 'install') {
- $github_app->installation_id = $installation_id;
- $github_app->save();
- }
+ $setup_action = (string) $request->query('setup_action', '');
+ abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
+ $installation_id = (string) $request->query('installation_id', '');
+ abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
+
+ if ($setup_action === 'update') {
+ return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
+ }
+
+ $github_app = $this->consumeGithubAppSetupState(
+ request: $request,
+ state: (string) $request->query('state', ''),
+ action: 'install',
+ );
+
+ abort_unless(
+ $this->githubInstallationBelongsToApp($github_app, $installation_id),
+ 403,
+ 'GitHub App installation could not be verified.'
+ );
+
+ $github_app->installation_id = $installation_id;
+ $github_app->save();
+
+ return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
+ }
+
+ private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
+ {
+ $github_app = GithubApp::ownedByCurrentTeam()
+ ->where('installation_id', $installation_id)
+ ->first();
+
+ if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
- } catch (Exception $e) {
- return handleError($e);
+ }
+
+ return redirect()->route('source.all');
+ }
+
+ /**
+ * Verify that the given installation id actually belongs to this GitHub App.
+ *
+ * The installation id arrives as an untrusted query parameter on an
+ * unauthenticated-reachable GET callback, so it must be confirmed against
+ * the GitHub API using the App's own credentials before it is persisted.
+ */
+ private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
+ {
+ if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
+ return false;
+ }
+
+ try {
+ $jwt = generateGithubJwt($github_app);
+ $response = Http::withHeaders([
+ 'Authorization' => "Bearer $jwt",
+ 'Accept' => 'application/vnd.github+json',
+ ])
+ ->timeout(10)
+ ->connectTimeout(5)
+ ->get("{$github_app->api_url}/app/installations/{$installation_id}");
+
+ return $response->successful()
+ && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
+ } catch (\Throwable) {
+ return false;
}
}
+
+ private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
+ {
+ if (blank($state)) {
+ $this->rejectInvalidGithubAppSetupState($request);
+ }
+
+ $payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
+ if (! is_array($payload) || data_get($payload, 'action') !== $action) {
+ $this->rejectInvalidGithubAppSetupState($request);
+ }
+
+ $team_id = $request->user()?->currentTeam()?->id;
+ abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
+
+ return GithubApp::whereKey(data_get($payload, 'github_app_id'))
+ ->where('team_id', data_get($payload, 'team_id'))
+ ->firstOrFail();
+ }
+
+ private function rejectInvalidGithubAppSetupState(Request $request): never
+ {
+ if ($request->expectsJson()) {
+ abort(404);
+ }
+
+ throw new HttpResponseException(
+ redirect()
+ ->route('source.all')
+ );
+ }
+
+ private function githubAppSetupStateCacheKey(string $state): string
+ {
+ return 'github-app-setup-state:'.hash('sha256', $state);
+ }
+
+ private function githubAppHasManifestCredentials(GithubApp $github_app): bool
+ {
+ return filled($github_app->app_id)
+ || filled($github_app->client_id)
+ || filled($github_app->client_secret)
+ || filled($github_app->webhook_secret)
+ || filled($github_app->private_key_id);
+ }
}
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 08e5d7162..231a0b6e5 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +15,9 @@ use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -32,6 +37,9 @@ class Gitlab extends Controller
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +66,7 @@ class Gitlab extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@@ -66,6 +75,9 @@ class Gitlab extends Controller
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
+ $pull_request_title = data_get($payload, 'object_attributes.title');
+ $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@@ -75,9 +87,18 @@ class Gitlab extends Controller
return response($return_payloads);
}
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ $return_payloads->push([
+ 'status' => 'failed',
+ 'message' => 'Nothing to do. Invalid repository.',
+ ]);
+
+ return response($return_payloads);
+ }
+ $applications = Application::query();
if ($x_gitlab_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -88,7 +109,7 @@ class Gitlab extends Controller
}
}
if ($x_gitlab_event === 'merge_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -100,12 +121,25 @@ class Gitlab extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
- if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
+ if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
+ auditLogWebhookFailure('gitlab', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -123,6 +157,17 @@ class Gitlab extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -141,6 +186,15 @@ class Gitlab extends Controller
'application_name' => $application->name,
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitlab',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -173,6 +227,15 @@ class Gitlab extends Controller
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index d59adf0ca..41e70b2ce 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ class Stripe extends Controller
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
- $event = \Stripe\Webhook::constructEvent(
+ $event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@@ -22,6 +24,12 @@ class Stripe extends Controller
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
+ } catch (SignatureVerificationException $e) {
+ auditLogWebhookFailure('stripe', 'invalid_signature', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 515d40c62..02a49aaa8 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -2,7 +2,41 @@
namespace App\Http;
+use App\Http\Middleware\ApiAbility;
+use App\Http\Middleware\ApiSensitiveData;
+use App\Http\Middleware\Authenticate;
+use App\Http\Middleware\CanAccessTerminal;
+use App\Http\Middleware\CanCreateResources;
+use App\Http\Middleware\CanUpdateResource;
+use App\Http\Middleware\CheckForcePasswordReset;
+use App\Http\Middleware\DecideWhatToDoWithUser;
+use App\Http\Middleware\EncryptCookies;
+use App\Http\Middleware\EnsureMcpEnabled;
+use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
+use App\Http\Middleware\PreventRequestsDuringMaintenance;
+use App\Http\Middleware\RedirectIfAuthenticated;
+use App\Http\Middleware\TrimStrings;
+use App\Http\Middleware\TrustHosts;
+use App\Http\Middleware\TrustProxies;
+use App\Http\Middleware\ValidateSignature;
+use App\Http\Middleware\VerifyCsrfToken;
+use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
+use Illuminate\Auth\Middleware\Authorize;
+use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
+use Illuminate\Auth\Middleware\RequirePassword;
+use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
+use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
+use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
+use Illuminate\Http\Middleware\HandleCors;
+use Illuminate\Http\Middleware\SetCacheHeaders;
+use Illuminate\Routing\Middleware\SubstituteBindings;
+use Illuminate\Routing\Middleware\ThrottleRequests;
+use Illuminate\Session\Middleware\AuthenticateSession;
+use Illuminate\Session\Middleware\StartSession;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+use Laravel\Sanctum\Http\Middleware\CheckAbilities;
+use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel
{
@@ -14,13 +48,13 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middleware = [
- \App\Http\Middleware\TrustHosts::class,
- \App\Http\Middleware\TrustProxies::class,
- \Illuminate\Http\Middleware\HandleCors::class,
- \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
- \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
- \App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+ TrustHosts::class,
+ TrustProxies::class,
+ HandleCors::class,
+ PreventRequestsDuringMaintenance::class,
+ ValidatePostSize::class,
+ TrimStrings::class,
+ ConvertEmptyStringsToNull::class,
];
@@ -31,21 +65,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
- \App\Http\Middleware\EncryptCookies::class,
- \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
- \Illuminate\Session\Middleware\StartSession::class,
- \Illuminate\View\Middleware\ShareErrorsFromSession::class,
- \App\Http\Middleware\VerifyCsrfToken::class,
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
- \App\Http\Middleware\CheckForcePasswordReset::class,
- \App\Http\Middleware\DecideWhatToDoWithUser::class,
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ ShareErrorsFromSession::class,
+ VerifyCsrfToken::class,
+ SubstituteBindings::class,
+ CheckForcePasswordReset::class,
+ DecideWhatToDoWithUser::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
- \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ThrottleRequests::class.':api',
+ SubstituteBindings::class,
],
];
@@ -57,22 +91,24 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middlewareAliases = [
- 'auth' => \App\Http\Middleware\Authenticate::class,
- 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
- 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
- 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
- 'can' => \Illuminate\Auth\Middleware\Authorize::class,
- 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
- 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
- 'signed' => \App\Http\Middleware\ValidateSignature::class,
- 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
- 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
- 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
- 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
- 'api.ability' => \App\Http\Middleware\ApiAbility::class,
- 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
- 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
- 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
- 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
+ 'auth' => Authenticate::class,
+ 'auth.basic' => AuthenticateWithBasicAuth::class,
+ 'auth.session' => AuthenticateSession::class,
+ 'cache.headers' => SetCacheHeaders::class,
+ 'can' => Authorize::class,
+ 'guest' => RedirectIfAuthenticated::class,
+ 'password.confirm' => RequirePassword::class,
+ 'signed' => ValidateSignature::class,
+ 'throttle' => ThrottleRequests::class,
+ 'verified' => EnsureEmailIsVerified::class,
+ 'abilities' => CheckAbilities::class,
+ 'ability' => CheckForAnyAbility::class,
+ 'api.ability' => ApiAbility::class,
+ 'api.sensitive' => ApiSensitiveData::class,
+ 'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class,
+ 'can.create.resources' => CanCreateResources::class,
+ 'can.update.resource' => CanUpdateResource::class,
+ 'can.access.terminal' => CanAccessTerminal::class,
+ 'mcp.enabled' => EnsureMcpEnabled::class,
];
}
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index 324eeebaa..f81c7d184 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
+use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@@ -14,11 +15,22 @@ class ApiAbility extends CheckForAnyAbility
}
return parent::handle($request, $next, ...$abilities);
- } catch (\Illuminate\Auth\AuthenticationException $e) {
+ } catch (AuthenticationException $e) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $e->getMessage(),
+ 'required_abilities' => $abilities,
+ ], 'warning');
+
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
+ auditLog('api.auth.ability_denied', [
+ 'required_abilities' => $abilities,
+ 'token_id' => $request->user()?->currentAccessToken()?->id,
+ 'reason' => $e->getMessage(),
+ ], 'warning');
+
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);
diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php
new file mode 100644
index 000000000..9c4f1339c
--- /dev/null
+++ b/app/Http/Middleware/EnsureMcpEnabled.php
@@ -0,0 +1,25 @@
+is_mcp_server_enabled) {
+ abort(404);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php
new file mode 100644
index 000000000..7c858b38b
--- /dev/null
+++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php
@@ -0,0 +1,37 @@
+user();
+ $token = $user?->currentAccessToken();
+ $teamId = $token?->team_id;
+
+ if (! $user || ! $token || is_null($teamId)) {
+ return response()->json(['message' => 'Invalid token.'], 401);
+ }
+
+ $team = $user->teams()
+ ->where('teams.id', $teamId)
+ ->first();
+
+ if (! $team) {
+ return response()->json(['message' => 'Invalid token.'], 401);
+ }
+
+ $role = $team->pivot?->role;
+ if (($token->can('root') || $token->can('write') || $token->can('write:sensitive'))
+ && ! in_array($role, ['admin', 'owner'], true)) {
+ return response()->json(['message' => 'Missing required team role.'], 403);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php
new file mode 100644
index 000000000..e7b34248e
--- /dev/null
+++ b/app/Jobs/ApiTokenExpirationWarningJob.php
@@ -0,0 +1,64 @@
+whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->whereNull('api_token_expiration_warning_sent_at')
+ ->where('tokenable_type', User::class)
+ ->chunkById(100, function ($tokens) {
+ foreach ($tokens as $token) {
+ if (! $token->team_id) {
+ continue;
+ }
+
+ $team = Team::find($token->team_id);
+ if (! $team) {
+ continue;
+ }
+
+ $warningSentAt = now();
+
+ $team->notify(new ApiTokenExpiringNotification($token));
+
+ $markedAsSent = PersonalAccessToken::query()
+ ->whereKey($token->getKey())
+ ->whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->whereNull('api_token_expiration_warning_sent_at')
+ ->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
+
+ if ($markedAsSent !== 1) {
+ continue;
+ }
+
+ $token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
+ }
+ });
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 41eb81453..811d0c9bd 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -33,6 +33,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
+use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
+ private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
+
+ private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
+
public $tries = 1;
public $timeout = 3600;
@@ -76,6 +81,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private ?string $dockerImageTag = null;
+ private ?string $dockerImagePreviewTag = null;
+
private GithubApp|GitlabApp|string $source = 'other';
private StandaloneDocker|SwarmDocker $destination;
@@ -122,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_nixpacks_args;
+ private $env_railpack_args;
+
private $docker_compose;
private $docker_compose_base64;
@@ -172,6 +181,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerBuildkitSupported = false;
+ private bool $dockerBuildxAvailable = false;
+
private bool $dockerSecretsSupported = false;
private bool $skip_build = false;
@@ -186,7 +197,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public int $application_deployment_queue_id)
{
- $this->onQueue('high');
+ $this->onQueue(deployment_queue());
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
$this->nixpacks_plan_json = collect([]);
@@ -208,6 +219,8 @@ 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->validateDockerRegistryImageConfiguration();
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
@@ -246,6 +259,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 +304,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);
@@ -407,6 +424,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
@@ -420,8 +438,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
+ $this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
+ $this->dockerBuildxAvailable = false;
+
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@@ -454,6 +475,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
@@ -465,20 +487,24 @@ 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') {
$this->deploy_static_buildpack();
- } else {
+ } elseif ($this->application->build_pack === 'nixpacks') {
$this->deploy_nixpacks_buildpack();
+ } elseif ($this->application->build_pack === 'railpack') {
+ $this->deploy_railpack_buildpack();
+ } else {
+ throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
}
$this->post_deployment();
}
@@ -512,11 +538,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
- try {
- $this->application->isConfigurationChanged(true);
- } catch (Exception $e) {
- \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
- }
}
private function deploy_simple_dockerfile()
@@ -553,11 +574,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 +591,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')) {
@@ -922,6 +952,37 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->rolling_update();
}
+ private function deploy_railpack_buildpack(): void
+ {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ }
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
+ $this->prepare_builder_image();
+ $this->check_git_if_build_needed();
+ $this->generate_image_names();
+ if (! $this->force_rebuild) {
+ $this->check_image_locally_or_remotely();
+ if ($this->should_skip_build()) {
+ return;
+ }
+ }
+ $this->clone_repository();
+ $this->cleanup_git();
+ $this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
+ $this->generate_build_env_variables();
+ $this->build_railpack_image();
+
+ // Save runtime environment variables AFTER the build
+ $this->save_runtime_environment_variables();
+ $this->push_to_docker_registry();
+ $this->rolling_update();
+ }
+
private function deploy_static_buildpack()
{
if ($this->use_build_server) {
@@ -1046,7 +1107,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'hidden' => true,
],
);
- if ($this->application->docker_registry_image_tag) {
+ if ($this->shouldPushDockerRegistryImageTag()) {
// Tag image with docker_registry_image_tag
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
$this->execute_remote_command(
@@ -1070,6 +1131,30 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ private function shouldPushDockerRegistryImageTag(): bool
+ {
+ if (blank($this->application->docker_registry_image_tag)) {
+ return false;
+ }
+
+ return $this->pull_request_id === 0;
+ }
+
+ private function validateDockerRegistryImageConfiguration(): void
+ {
+ if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
+ throw new DeploymentException('Docker registry image name contains invalid characters.');
+ }
+
+ if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
+ throw new DeploymentException('Docker registry image tag contains invalid characters.');
+ }
+
+ if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
+ throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
+ }
+ }
+
private function generate_image_names()
{
if ($this->application->dockerfile) {
@@ -1089,12 +1174,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) {
+ $previewImageTag = $this->previewImageTag();
+ $previewBuildImageTag = $this->previewImageTag(build: true);
+
if ($this->application->docker_registry_image_name) {
- $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
} else {
- $this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
}
} else {
$this->dockerImageTag = str($this->commit)->substr(0, 128);
@@ -1111,6 +1199,27 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ private function previewImageTag(bool $build = false): string
+ {
+ $prefix = "pr-{$this->pull_request_id}-";
+ $suffix = $build ? '-build' : '';
+ $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
+ $commitSource = ($this->commit === 'HEAD' || blank($this->commit))
+ ? $this->deployment_uuid
+ : $this->commit;
+
+ $commit = Str::of($commitSource)
+ ->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
+ ->substr(0, $maxCommitLength)
+ ->toString();
+
+ if ($commit === '') {
+ $commit = 'HEAD';
+ }
+
+ return "{$prefix}{$commit}{$suffix}";
+ }
+
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
@@ -1149,8 +1258,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
- if (! $this->application->isConfigurationChanged()) {
- $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
+ $configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
+ if (! $configurationDiff->requiresBuild()) {
+ $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true;
$this->generate_compose_file();
@@ -1162,7 +1272,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
} else {
- $this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.');
+ $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
}
} else {
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
@@ -1201,19 +1311,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('key');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('id');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
- });
- $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
$ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables();
@@ -1266,7 +1372,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 +1438,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 +1452,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));
}
}
@@ -1366,6 +1472,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $envs;
}
+ private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
+ {
+ $key = str($environmentVariable->key);
+
+ return $key->startsWith('SERVICE_FQDN_')
+ || $key->startsWith('SERVICE_URL_')
+ || $key->startsWith('SERVICE_NAME_');
+ }
+
private function save_runtime_environment_variables()
{
// This method saves the .env file with ALL runtime variables
@@ -1576,22 +1691,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
+ // For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
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 +1718,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,29 +1735,29 @@ 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}");
}
}
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
+ // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
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 +1769,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 +1786,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 +2048,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();
@@ -1960,7 +2080,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
- $this->build_image();
+ if ($this->application->build_pack === 'railpack') {
+ $this->build_railpack_image();
+ } else {
+ $this->build_image();
+ }
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@@ -2005,19 +2129,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
+ instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets();
+ $buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
}
- $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}";
+ $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -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 {$buildxMetadataVolume} -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 {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
if ($firstTry) {
@@ -2122,11 +2250,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
if (isset($this->application->git_branch)) {
- $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
+ $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
}
+ private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
+ {
+ $sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
+
+ if ($identityFile !== null) {
+ $sshCommand .= " -i {$identityFile}";
+ }
+
+ return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
+ }
+
private function check_git_if_build_needed()
{
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
@@ -2172,7 +2311,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
- executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')),
'hidden' => true,
'save' => 'git_commit_sha',
]
@@ -2180,7 +2319,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
'hidden' => true,
'save' => 'git_commit_sha',
],
@@ -2369,15 +2508,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}"));
}
}
@@ -2395,7 +2536,409 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
- private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
+ private function generate_railpack_env_variables(): Collection
+ {
+ $variables = $this->railpack_build_variables();
+
+ $this->env_railpack_args = $variables
+ ->map(function ($value, $key) {
+ return '--env '.escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ');
+
+ return $variables;
+ }
+
+ private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
+ {
+ $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
+ if (is_null($resolvedValue) || $resolvedValue === '') {
+ return null;
+ }
+
+ if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
+ return trim($resolvedValue, "'");
+ }
+
+ return $resolvedValue;
+ }
+
+ /**
+ * All buildtime variables that must reach the Railpack build.
+ *
+ * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
+ * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
+ * on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
+ * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret`
+ * channel is the only way user-defined buildtime variables become available to
+ * commands declared with `useSecrets: true`.
+ */
+ private function railpack_build_variables(): Collection
+ {
+ $genericBuildVariables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
+
+ $railpackVariables = $this->pull_request_id === 0
+ ? $this->application->railpack_environment_variables()->get()
+ : $this->application->railpack_environment_variables_preview()->get();
+
+ $variables = $genericBuildVariables
+ ->merge($railpackVariables)
+ ->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
+ $value = $this->normalize_resolved_build_variable_value($environmentVariable);
+ if (is_null($value) || $value === '') {
+ return [];
+ }
+
+ return [$environmentVariable->key => $value];
+ });
+
+ if ($this->application->install_command) {
+ $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
+ }
+
+ $variables = $this->merge_railpack_deploy_apt_packages($variables);
+
+ // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
+ // (e.g. SPAs baking the public URL) can read them via /run/secrets/.
+ foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
+ if (! is_null($value) && $value !== '') {
+ $variables->put($key, $value);
+ }
+ }
+
+ return $variables;
+ }
+
+ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
+ {
+ $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
+ ->filter()
+ ->values();
+
+ foreach (['curl', 'wget'] as $package) {
+ if (! $packages->contains($package)) {
+ $packages->push($package);
+ }
+ }
+
+ $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
+
+ return $variables;
+ }
+
+ private function railpack_build_environment_prefix(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return 'env '.$variables
+ ->map(function ($value, $key) {
+ return escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ').' ';
+ }
+
+ private function railpack_build_secret_flags(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return ' '.$variables
+ ->map(function ($value, $key) {
+ return '--secret '.escapeShellValue("id={$key},env={$key}");
+ })
+ ->implode(' ');
+ }
+
+ private function railpack_build_command(string $imageName, Collection $variables): string
+ {
+ $cacheArgs = '';
+ if ($this->force_rebuild) {
+ $cacheArgs = '--no-cache';
+ } else {
+ $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
+ }
+
+ if ($variables->isNotEmpty()) {
+ $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
+ }
+
+ $environmentPrefix = $this->railpack_build_environment_prefix($variables);
+ $secretFlags = $this->railpack_build_secret_flags($variables);
+ $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
+
+ return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
+ ." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
+ ." {$this->addHosts} --network host"
+ ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
+ ." {$cacheArgs}"
+ ."{$secretFlags}"
+ .' -f /artifacts/railpack-plan.json'
+ .' --progress plain'
+ .' --load'
+ ." -t {$imageName}"
+ ." {$this->workdir}";
+ }
+
+ private function decode_railpack_config(string $config, string $source): array
+ {
+ try {
+ $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ if (! is_array($decoded)) {
+ throw new DeploymentException("Invalid {$source}: expected a JSON object.");
+ }
+
+ return $decoded;
+ }
+
+ private function is_assoc_array(array $value): bool
+ {
+ if ($value === []) {
+ return false;
+ }
+
+ return array_keys($value) !== range(0, count($value) - 1);
+ }
+
+ private function merge_railpack_config(array $base, array $overrides): array
+ {
+ foreach ($overrides as $key => $value) {
+ if (
+ array_key_exists($key, $base)
+ && is_array($base[$key])
+ && is_array($value)
+ && $this->is_assoc_array($base[$key])
+ && $this->is_assoc_array($value)
+ ) {
+ $base[$key] = $this->merge_railpack_config($base[$key], $value);
+ } else {
+ $base[$key] = $value;
+ }
+ }
+
+ return $base;
+ }
+
+ private function railpack_config_overrides(): array
+ {
+ return [];
+ }
+
+ private function generated_railpack_config_relative_path(): string
+ {
+ return self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generated_railpack_config_absolute_path(): string
+ {
+ return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generate_railpack_config_file(): ?string
+ {
+ $repositoryConfig = [];
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
+ 'hidden' => true,
+ 'save' => 'railpack_config_exists',
+ ]);
+
+ if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
+ 'hidden' => true,
+ 'save' => 'railpack_repository_config',
+ ]);
+
+ $repositoryConfig = $this->decode_railpack_config(
+ $this->saved_outputs->get('railpack_repository_config', ''),
+ 'repository railpack.json'
+ );
+ }
+
+ $overrides = $this->railpack_config_overrides();
+ if ($repositoryConfig === [] && $overrides === []) {
+ return null;
+ }
+
+ $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
+ if (! array_key_exists('$schema', $mergedConfig)) {
+ $mergedConfig['$schema'] = 'https://schema.railpack.com';
+ }
+
+ try {
+ $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ $configPath = $this->generated_railpack_config_absolute_path();
+ $encodedConfig = base64_encode($encodedConfig);
+
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
+ 'hidden' => true,
+ ]
+ );
+
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
+ }
+
+ return $this->generated_railpack_config_relative_path();
+ }
+
+ private function railpack_prepare_command(?string $configFilePath = null): string
+ {
+ $prepare_command = 'railpack prepare';
+
+ if ($this->application->build_command) {
+ $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
+ }
+
+ if ($this->application->start_command) {
+ $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
+ }
+
+ if ($this->env_railpack_args) {
+ $prepare_command .= " {$this->env_railpack_args}";
+ }
+
+ if ($configFilePath) {
+ $prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
+ }
+
+ $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
+
+ return $prepare_command;
+ }
+
+ private function ensure_docker_buildx_available_for_railpack(): void
+ {
+ if ($this->dockerBuildxAvailable) {
+ return;
+ }
+
+ throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
+ }
+
+ private function build_railpack_image(): void
+ {
+ $this->ensure_docker_buildx_available_for_railpack();
+
+ $railpackVariables = $this->generate_railpack_env_variables();
+ $railpackConfigPath = $this->generate_railpack_config_file();
+
+ // Step 1: Generate build plan with railpack prepare
+ $prepare_command = $this->railpack_prepare_command($railpackConfigPath);
+
+ $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
+ 'hidden' => true,
+ 'save' => 'railpack_plan',
+ ],
+ );
+
+ $railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
+ if (! empty($railpackPlanRaw)) {
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
+ } else {
+ $parsedPlan = json_decode($railpackPlanRaw, true);
+ if (is_array($parsedPlan)) {
+ // Strip secrets array to avoid logging variable names in production.
+ unset($parsedPlan['secrets']);
+ $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
+ }
+ }
+ }
+
+ // Step 2: Build image using docker buildx with railpack frontend.
+ // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
+ $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
+ $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
+
+ $image_name = $this->application->settings->is_static
+ ? $this->build_image_name
+ : $this->production_image_name;
+
+ if ($this->application->settings->is_static && $this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+
+ $build_command = $this->railpack_build_command($image_name, $railpackVariables);
+
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ]
+ );
+
+ // Step 3: If static, copy built assets into nginx image
+ if ($this->application->settings->is_static) {
+ $this->build_railpack_static_image();
+ }
+ }
+
+ private function build_railpack_static_image(): void
+ {
+ $publishDir = trim($this->application->publish_directory, '/');
+ $publishDir = $publishDir ? "/{$publishDir}" : '';
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+WORKDIR /usr/share/nginx/html/
+LABEL coolify.deploymentId={$this->deployment_uuid}
+COPY --from={$this->build_image_name} /app{$publishDir} .
+COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ $nginx_config = $this->application->settings->is_spa
+ ? base64_encode(defaultNginxConfiguration('spa'))
+ : base64_encode(defaultNginxConfiguration());
+ }
+
+ $static_build = $this->dockerBuildkitSupported
+ ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
+ : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
+
+ $base64_static_build = base64_encode($static_build);
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ );
+ }
+
+ protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
@@ -2511,24 +3054,34 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
+ if ($this->build_pack === 'dockercompose') {
+ $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ }
+
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 {
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
+ if ($this->build_pack === 'dockercompose') {
+ $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ }
+
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);
}
}
}
@@ -2585,7 +3138,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'image' => $this->production_image_name,
'container_name' => $this->container_name,
'restart' => RESTART_MODE,
- ...(!empty($ports) ? ['expose' => $ports] : []),
+ ...(! empty($ports) ? ['expose' => $ports] : []),
'networks' => [
$this->destination->network => [
'aliases' => array_merge(
@@ -2855,7 +3408,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
- ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
+ ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
@@ -3058,23 +3611,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$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} {$this->addHosts} --network host -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} {$this->addHosts} --network host -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} {$this->addHosts} --network host -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} {$this->addHosts} --network host -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} {$this->addHosts} --network host -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} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3287,14 +3840,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
- $timeout = isDev() ? 1 : 30;
+ $timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
+
if ($skipRemove) {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
@@ -3549,7 +4103,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,14 +4162,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
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,14 +4184,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
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
@@ -3673,7 +4227,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}");
@@ -4013,6 +4567,51 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return $value;
}
+ /**
+ * Resolve which container to execute a deployment command in.
+ *
+ * For single-container apps, returns the sole container.
+ * For multi-container apps, matches by the user-specified container name.
+ * If no container name is specified for multi-container apps, logs available containers and returns null.
+ */
+ private function resolveCommandContainer(Collection $containers, ?string $specifiedContainerName, string $commandType): ?array
+ {
+ if ($containers->count() === 0) {
+ return null;
+ }
+
+ if ($containers->count() === 1) {
+ return $containers->first();
+ }
+
+ // Multi-container: require a container name to be specified
+ if (empty($specifiedContainerName)) {
+ $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
+ $this->application_deployment_queue->addLogEntry(
+ "{$commandType} command: Multiple containers found but no container name specified. Available: {$available}"
+ );
+
+ return null;
+ }
+
+ // Multi-container: match by specified name prefix
+ $prefix = $specifiedContainerName.'-'.$this->application->uuid;
+ foreach ($containers as $container) {
+ $containerName = data_get($container, 'Names');
+ if (str_starts_with($containerName, $prefix)) {
+ return $container;
+ }
+ }
+
+ // No match found — log available containers to help the user debug
+ $available = $containers->map(fn ($c) => data_get($c, 'Names'))->implode(', ');
+ $this->application_deployment_queue->addLogEntry(
+ "{$commandType} command: Container '{$specifiedContainerName}' not found. Available: {$available}"
+ );
+
+ return null;
+ }
+
private function run_pre_deployment_command()
{
if (empty($this->application->pre_deployment_command)) {
@@ -4020,39 +4619,39 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
if ($containers->count() == 0) {
+ $this->application_deployment_queue->addLogEntry('Pre-deployment command: No running containers found. Skipping.');
+
return;
}
$this->application_deployment_queue->addLogEntry('Executing pre-deployment command (see debug log for output/errors).');
- foreach ($containers as $container) {
- $containerName = data_get($container, 'Names');
- if ($containerName) {
- $this->validateContainerName($containerName);
- }
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->pre_deployment_command_container.'-'.$this->application->uuid)) {
- // Security: pre_deployment_command is intentionally treated as arbitrary shell input.
- // Users (team members with deployment access) need full shell flexibility to run commands
- // like "php artisan migrate", "npm run build", etc. inside their own application containers.
- // The trust boundary is at the application/team ownership level — only authenticated team
- // members can set these commands, and execution is scoped to the application's own container.
- // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
- // restrict the command itself. Container names are validated separately via validateContainerName().
- // Newlines are normalized to spaces to prevent injection via SSH heredoc transport
- // (matches the pattern used for health_check_command at line ~2824).
- $preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command);
- $cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'";
- $exec = "docker exec {$containerName} {$cmd}";
- $this->execute_remote_command(
- [
- 'command' => $exec,
- 'hidden' => true,
- ],
- );
-
- return;
- }
+ $container = $this->resolveCommandContainer($containers, $this->application->pre_deployment_command_container, 'Pre-deployment');
+ if ($container === null) {
+ throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
}
- throw new DeploymentException('Pre-deployment command: Could not find a valid container. Is the container name correct?');
+
+ $containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
+ // Security: pre_deployment_command is intentionally treated as arbitrary shell input.
+ // Users (team members with deployment access) need full shell flexibility to run commands
+ // like "php artisan migrate", "npm run build", etc. inside their own application containers.
+ // The trust boundary is at the application/team ownership level — only authenticated team
+ // members can set these commands, and execution is scoped to the application's own container.
+ // The single-quote escaping here prevents breaking out of the sh -c wrapper, but does not
+ // restrict the command itself. Container names are validated separately via validateContainerName().
+ // Newlines are normalized to spaces to prevent injection via SSH heredoc transport
+ // (matches the pattern used for health_check_command at line ~2824).
+ $preCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->pre_deployment_command);
+ $cmd = "sh -c '".str_replace("'", "'\''", $preCommand)."'";
+ $exec = "docker exec {$containerName} {$cmd}";
+ $this->execute_remote_command(
+ [
+ 'command' => $exec,
+ 'hidden' => true,
+ ],
+ );
}
private function run_post_deployment_command()
@@ -4064,38 +4663,42 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->application_deployment_queue->addLogEntry('Executing post-deployment command (see debug log for output).');
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
- foreach ($containers as $container) {
- $containerName = data_get($container, 'Names');
- if ($containerName) {
- $this->validateContainerName($containerName);
- }
- if ($containers->count() == 1 || str_starts_with($containerName, $this->application->post_deployment_command_container.'-'.$this->application->uuid)) {
- // Security: post_deployment_command is intentionally treated as arbitrary shell input.
- // See the equivalent comment in run_pre_deployment_command() for the full security rationale.
- // Newlines are normalized to spaces to prevent injection via SSH heredoc transport.
- $postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command);
- $cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'";
- $exec = "docker exec {$containerName} {$cmd}";
- try {
- $this->execute_remote_command(
- [
- 'command' => $exec,
- 'hidden' => true,
- 'save' => 'post-deployment-command-output',
- ],
- );
- } catch (Exception $e) {
- $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
- if ($post_deployment_command_output) {
- $this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
- $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
- }
- }
+ if ($containers->count() == 0) {
+ $this->application_deployment_queue->addLogEntry('Post-deployment command: No running containers found. Skipping.');
- return;
+ return;
+ }
+
+ $container = $this->resolveCommandContainer($containers, $this->application->post_deployment_command_container, 'Post-deployment');
+ if ($container === null) {
+ throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
+ }
+
+ $containerName = data_get($container, 'Names');
+ if ($containerName) {
+ $this->validateContainerName($containerName);
+ }
+ // Security: post_deployment_command is intentionally treated as arbitrary shell input.
+ // See the equivalent comment in run_pre_deployment_command() for the full security rationale.
+ // Newlines are normalized to spaces to prevent injection via SSH heredoc transport.
+ $postCommand = str_replace(["\r\n", "\r", "\n"], ' ', $this->application->post_deployment_command);
+ $cmd = "sh -c '".str_replace("'", "'\''", $postCommand)."'";
+ $exec = "docker exec {$containerName} {$cmd}";
+ try {
+ $this->execute_remote_command(
+ [
+ 'command' => $exec,
+ 'hidden' => true,
+ 'save' => 'post-deployment-command-output',
+ ],
+ );
+ } catch (Exception $e) {
+ $post_deployment_command_output = $this->saved_outputs->get('post-deployment-command-output');
+ if ($post_deployment_command_output) {
+ $this->application_deployment_queue->addLogEntry('Post-deployment command failed.');
+ $this->application_deployment_queue->addLogEntry($post_deployment_command_output, 'stderr');
}
}
- throw new DeploymentException('Post-deployment command: Could not find a valid container. Is the container name correct?');
}
/**
@@ -4185,6 +4788,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'last_restart_type' => null,
]);
+ try {
+ $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
+ } catch (Exception $e) {
+ \Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
diff --git a/app/Jobs/CleanupInstanceStuffsJob.php b/app/Jobs/CleanupInstanceStuffsJob.php
index 011c58639..e37a39c3d 100644
--- a/app/Jobs/CleanupInstanceStuffsJob.php
+++ b/app/Jobs/CleanupInstanceStuffsJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Models\ScheduledDatabaseBackup;
use App\Models\TeamInvitation;
use App\Models\User;
use Illuminate\Bus\Queueable;
@@ -12,6 +13,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
@@ -32,6 +34,7 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
try {
$this->cleanupInvitationLink();
$this->cleanupExpiredEmailChangeRequests();
+ $this->enforceBackupRetention();
} catch (\Throwable $e) {
Log::error('CleanupInstanceStuffsJob failed with error: '.$e->getMessage());
}
@@ -55,4 +58,25 @@ class CleanupInstanceStuffsJob implements ShouldBeEncrypted, ShouldBeUnique, Sho
'email_change_code_expires_at' => null,
]);
}
+
+ private function enforceBackupRetention(): void
+ {
+ if (! Cache::add('backup-retention-enforcement', true, 1800)) {
+ return;
+ }
+
+ try {
+ $backups = ScheduledDatabaseBackup::where('enabled', true)->get();
+ foreach ($backups as $backup) {
+ try {
+ removeOldBackups($backup);
+ } catch (\Throwable $e) {
+ Log::warning('Failed to enforce retention for backup '.$backup->id.': '.$e->getMessage());
+ }
+ }
+ } catch (\Throwable $e) {
+ Log::error('Failed to enforce backup retention: '.$e->getMessage());
+ Cache::forget('backup-retention-enforcement');
+ }
+ }
}
diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php
index 6d49bee4b..0d3029c66 100644
--- a/app/Jobs/CleanupStaleMultiplexedConnections.php
+++ b/app/Jobs/CleanupStaleMultiplexedConnections.php
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
@@ -20,6 +21,132 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
{
$this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections();
+ $this->cleanupOrphanedSshProcesses();
+ $this->cleanupOrphanedCloudflaredProcesses();
+ }
+
+ /**
+ * Kill backgrounded ssh master processes that lost the ControlPath socket
+ * race. Such processes are not masters, so ControlPersist never reaps them
+ * and they leak memory until the container restarts. A legitimate master
+ * always owns its socket file; an orphan has none.
+ *
+ * Processes younger than the minimum age are skipped: a freshly forked
+ * master creates its socket a few milliseconds after starting, so a young
+ * process with no socket may simply be mid-establish rather than orphaned.
+ */
+ private function cleanupOrphanedSshProcesses(): void
+ {
+ $muxDir = storage_path('app/ssh/mux');
+ $minAge = (int) config('constants.ssh.mux_orphan_min_age');
+
+ foreach ($this->listProcesses() as $process) {
+ // Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
+ if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
+ continue;
+ }
+
+ // Only ever touch ssh processes pointing at Coolify's mux directory.
+ if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
+ continue;
+ }
+
+ if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
+ $this->reapOrphan('ssh', $process);
+ }
+ }
+ }
+
+ /**
+ * Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
+ * as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
+ * die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
+ * mux master), the cloudflared process can leak and accumulate. A legitimate
+ * proxy always has a live ssh parent; one without is safe to reap.
+ *
+ * Processes younger than the minimum age are skipped so a proxy whose parent
+ * ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
+ * never mistaken for an orphan.
+ */
+ private function cleanupOrphanedCloudflaredProcesses(): void
+ {
+ $minAge = (int) config('constants.ssh.mux_orphan_min_age');
+ $processes = $this->listProcesses();
+
+ $sshPids = [];
+ foreach ($processes as $process) {
+ // The ssh binary itself, not `cloudflared access ssh` (space before ssh).
+ if (preg_match('#(^|/)ssh\s#', $process['args'])) {
+ $sshPids[$process['pid']] = true;
+ }
+ }
+
+ foreach ($processes as $process) {
+ // `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
+ if (! str_contains($process['args'], 'cloudflared access ssh')) {
+ continue;
+ }
+
+ // Orphaned when no live ssh process is its parent.
+ if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
+ $this->reapOrphan('cloudflared', $process);
+ }
+ }
+ }
+
+ /**
+ * Reap a detected orphan process. When orphan reaping is disabled (the
+ * default), the orphan is only logged — a dry-run mode that lets operators
+ * verify what would be killed before enabling it for real.
+ *
+ * @param array{pid: string, ppid: string, etimes: int, args: string} $process
+ */
+ private function reapOrphan(string $kind, array $process): void
+ {
+ if (! config('constants.ssh.mux_orphan_reap_enabled')) {
+ Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
+ 'pid' => $process['pid'],
+ 'etimes' => $process['etimes'],
+ 'command' => $process['args'],
+ ]);
+
+ return;
+ }
+
+ Process::run('kill '.escapeshellarg($process['pid']));
+ Log::info("Killed orphaned {$kind} process", [
+ 'pid' => $process['pid'],
+ 'etimes' => $process['etimes'],
+ 'command' => $process['args'],
+ ]);
+ }
+
+ /**
+ * Snapshot of running processes.
+ *
+ * @return list
+ */
+ private function listProcesses(): array
+ {
+ $ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
+ if ($ps->exitCode() !== 0) {
+ return [];
+ }
+
+ $processes = [];
+ foreach (explode("\n", trim($ps->output())) as $line) {
+ if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
+ continue;
+ }
+ $processes[] = [
+ 'pid' => $matches[1],
+ 'ppid' => $matches[2],
+ 'etimes' => (int) $matches[3],
+ 'args' => $matches[4],
+ ];
+ }
+
+ return $processes;
}
private function cleanupStaleConnections()
@@ -31,7 +158,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'server_not_found');
continue;
}
@@ -41,14 +168,14 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'expired');
}
}
}
@@ -62,7 +189,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'server_does_not_exist');
}
}
}
@@ -72,11 +199,30 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
return substr($muxFile, 4);
}
- private function removeMultiplexFile($muxFile)
+ /**
+ * Close and delete a stale mux socket file. When orphan reaping is disabled
+ * (the default), the file is only logged — a dry-run mode that lets operators
+ * verify what would be removed before enabling it for real.
+ */
+ private function removeMultiplexFile(string $muxFile, string $reason): void
{
+ if (! config('constants.ssh.mux_orphan_reap_enabled')) {
+ Log::info('Stale mux file detected (dry-run, not removed)', [
+ 'file' => $muxFile,
+ 'reason' => $reason,
+ ]);
+
+ return;
+ }
+
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile);
+
+ Log::info('Removed stale mux file', [
+ 'file' => $muxFile,
+ 'reason' => $reason,
+ ]);
}
}
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index d86986fad..64e900b49 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -22,6 +22,7 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
+use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@@ -76,10 +77,17 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
- $this->onQueue('high');
+ $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600;
}
+ public function middleware(): array
+ {
+ $expireAfter = ($this->backup->timeout ?? 3600) + 300;
+
+ return [(new WithoutOverlapping('database-backup-'.$this->backup->id))->expireAfter($expireAfter)->dontRelease()];
+ }
+
public function handle(): void
{
try {
@@ -91,7 +99,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$this->database = data_get($this->backup, 'database');
$this->server = $this->database->service->server;
$this->s3 = $this->backup->s3;
@@ -107,6 +115,8 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
throw new \Exception('Database not found?!');
}
+ $this->markStaleExecutionsAsFailed();
+
BackupCreated::dispatch($this->team->id);
$status = str(data_get($this->database, 'status'));
@@ -119,7 +129,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return;
}
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$databaseType = $this->database->databaseType();
$serviceUuid = $this->database->service->uuid;
$serviceName = str($this->database->service->name)->slug();
@@ -241,7 +251,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// Continue without env vars - will be handled in backup_standalone_mongodb method
}
}
@@ -388,7 +398,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
throw new \Exception('Local backup file is empty or was not created');
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
@@ -401,7 +411,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
}
try {
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
- } catch (\Throwable $notifyException) {
+ } catch (Throwable $notifyException) {
Log::channel('scheduled-errors')->warning('Failed to send backup failure notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
@@ -423,7 +433,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
@@ -455,7 +465,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
} else {
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup success notification', [
'backup_id' => $this->backup->uuid,
'database' => $database,
@@ -467,7 +477,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup_log && $this->backup_log->status === 'success') {
removeOldBackups($this->backup);
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
throw $e;
} finally {
if ($this->team) {
@@ -489,19 +499,23 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
// For service-based MongoDB, try to build URL from environment variables
if (filled($this->mongo_root_username) && filled($this->mongo_root_password)) {
// Use container name instead of server IP for service-based MongoDB
- $url = "mongodb://{$this->mongo_root_username}:{$this->mongo_root_password}@{$this->container_name}:27017";
+ // URL-encode credentials to prevent URI injection
+ $encodedUser = rawurlencode($this->mongo_root_username);
+ $encodedPass = rawurlencode($this->mongo_root_password);
+ $url = "mongodb://{$encodedUser}:{$encodedPass}@{$this->container_name}:27017";
} else {
// If no environment variables are available, throw an exception
throw new \Exception('MongoDB credentials not found. Ensure MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD environment variables are available in the container.');
}
}
Log::info('MongoDB backup URL configured', ['has_url' => filled($url), 'using_env_vars' => blank($this->database->internal_db_url)]);
+ $escapedUrl = escapeshellarg($url);
if ($databaseWithCollections === 'all') {
$commands[] = 'mkdir -p '.$this->backup_dir;
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --gzip --archive > $this->backup_location";
}
} else {
if (str($databaseWithCollections)->contains(':')) {
@@ -519,9 +533,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($collectionsToExclude->count() === 0) {
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --archive > $this->backup_location";
}
} else {
// Validate and escape each collection name
@@ -533,9 +547,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
});
if (str($this->database->image)->startsWith('mongo:4')) {
- $commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --uri=$escapedUrl --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
} else {
- $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=$escapedUrl --db $escapedDatabaseName --gzip --excludeCollection ".$escapedCollections->implode(' --excludeCollection ')." --archive > $this->backup_location";
}
}
}
@@ -544,7 +558,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -556,15 +570,16 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$commands[] = 'mkdir -p '.$this->backup_dir;
$backupCommand = 'docker exec';
if ($this->postgres_password) {
- $backupCommand .= " -e PGPASSWORD=\"{$this->postgres_password}\"";
+ $backupCommand .= ' -e PGPASSWORD='.escapeshellarg($this->postgres_password);
}
+ $escapedUsername = escapeshellarg($this->database->postgres_user);
if ($this->backup->dump_all) {
- $backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
+ $backupCommand .= " $this->container_name pg_dumpall --username $escapedUsername | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
+ $backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username $escapedUsername $escapedDatabase > $this->backup_location";
}
$commands[] = $backupCommand;
@@ -573,7 +588,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -583,20 +598,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
+ $escapedPassword = escapeshellarg($this->database->mysql_root_password);
if ($this->backup->dump_all) {
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mysqldump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -606,20 +622,21 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$commands[] = 'mkdir -p '.$this->backup_dir;
+ $escapedPassword = escapeshellarg($this->database->mariadb_root_password);
if ($this->backup->dump_all) {
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
} else {
// Validate and escape database name to prevent command injection
validateShellSafePath($database, 'database name');
$escapedDatabase = escapeshellarg($database);
- $commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
+ $commands[] = "docker exec $this->container_name mariadb-dump -u root -p$escapedPassword $escapedDatabase > $this->backup_location";
}
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
$this->backup_output = trim($this->backup_output);
if ($this->backup_output === '') {
$this->backup_output = null;
}
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->add_to_error_output($e->getMessage());
throw $e;
}
@@ -651,12 +668,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
+ $previousS3StorageId = $this->backup->s3_storage_id;
+
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
- throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
+ throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
@@ -666,11 +685,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$bucket = $this->s3->bucket;
$endpoint = $this->s3->endpoint;
$this->s3->testConnection(shouldSave: true);
- if (data_get($this->backup, 'database_type') === \App\Models\ServiceDatabase::class) {
+ if (data_get($this->backup, 'database_type') === ServiceDatabase::class) {
$network = $this->database->service->destination->network;
} else {
$network = $this->database->destination->network;
}
+ $safeNetwork = escapeshellarg($network);
$fullImageName = $this->getFullImageName();
@@ -682,13 +702,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
@@ -701,7 +721,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
$this->s3_uploaded = false;
$this->add_to_error_output($e->getMessage());
throw $e;
@@ -719,6 +739,31 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
return "{$helperImage}:{$latestVersion}";
}
+ private function markStaleExecutionsAsFailed(): void
+ {
+ try {
+ $timeoutSeconds = ($this->backup->timeout ?? 3600) * 2;
+
+ $staleExecutions = $this->backup->executions()
+ ->where('status', 'running')
+ ->where('created_at', '<', now()->subSeconds($timeoutSeconds))
+ ->get();
+
+ foreach ($staleExecutions as $execution) {
+ $execution->update([
+ 'status' => 'failed',
+ 'message' => 'Marked as failed - backup execution exceeded maximum allowed time',
+ 'finished_at' => now(),
+ ]);
+ }
+ } catch (Throwable $e) {
+ Log::channel('scheduled-errors')->warning('Failed to clean up stale backup executions', [
+ 'backup_id' => $this->backup->uuid,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
public function failed(?Throwable $exception): void
{
Log::channel('scheduled-errors')->error('DatabaseBackup permanently failed', [
@@ -755,7 +800,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$output = $this->backup_output ?? $exception?->getMessage() ?? 'Unknown error';
try {
$this->team->notify(new BackupFailed($this->backup, $this->database, $output, $databaseName));
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
Log::channel('scheduled-errors')->warning('Failed to send backup permanent failure notification', [
'backup_id' => $this->backup->uuid,
'error' => $e->getMessage(),
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 041cd812c..141351784 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@@ -17,6 +18,7 @@ use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,11 +33,13 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
+ public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
public ?string $authorAssociation,
public string $fullName,
+ public bool $isForkPullRequest = false,
) {
$this->onQueue('high');
}
@@ -83,9 +87,23 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
return;
}
+ if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
+ return;
+ }
+
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
- $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
+ // Fork PRs carry untrusted code from a repository outside our control.
+ // GitHub's author_association cannot be trusted to gate these (it grants
+ // CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
+ // PRs are never deployed automatically when public previews are off.
+ if ($this->isForkPullRequest) {
+ return;
+ }
+
+ // Same-repo (non-fork) branch PRs require push access to the base repo,
+ // so only trusted associations are allowed to trigger a deployment.
+ $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) {
return;
}
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index b1a12ae2a..62e98934e 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -13,6 +13,16 @@ use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+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 App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
@@ -25,6 +35,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $services;
+ public Collection $applicationsById;
+
+ public Collection $previewsByKey;
+
+ public Collection $databasesByUuid;
+
+ public Collection $servicesById;
+
+ public Collection $serviceApplicationsById;
+
+ public Collection $serviceDatabasesById;
+
public Collection $allApplicationIds;
public Collection $allDatabaseUuids;
@@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false;
+ private ?array $cachedDestinationIds = null;
+
public function middleware(): array
{
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
@@ -103,6 +128,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->allTcpProxyUuids = collect();
$this->allServiceApplicationIds = collect();
$this->allServiceDatabaseIds = collect();
+ $this->applicationsById = collect();
+ $this->previewsByKey = collect();
+ $this->databasesByUuid = collect();
+ $this->servicesById = collect();
+ $this->serviceApplicationsById = collect();
+ $this->serviceDatabasesById = collect();
}
public function handle()
@@ -120,6 +151,16 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= collect();
$this->allServiceDatabaseIds ??= collect();
+ $this->applicationsById ??= collect();
+ $this->previewsByKey ??= collect();
+ $this->databasesByUuid ??= collect();
+ $this->servicesById ??= collect();
+ $this->serviceApplicationsById ??= collect();
+ $this->serviceDatabasesById ??= collect();
+
+ // Eager-load relations the job touches repeatedly to avoid lazy-load queries
+ // (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
+ $this->server->loadMissing(['settings', 'team']);
// TODO: Swarm is not supported yet
if (! $this->data) {
@@ -127,30 +168,40 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$data = collect($this->data);
- $this->server->sentinelHeartbeat();
-
+ // Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
- // Only dispatch storage check when disk percentage actually changes
+ // Only dispatch the storage check when disk usage is at/above the notification
+ // threshold AND the value changed. Below the threshold ServerStorageCheckJob
+ // has nothing to do (it only sends a HighDiskUsage notification), so dispatching
+ // it is wasted work — and most servers sit well below the threshold.
+ $diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
$storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey);
- if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
+ if ($filesystemUsageRoot !== null
+ && $filesystemUsageRoot >= $diskThreshold
+ && (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
+ } elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
+ Cache::forget($storageCacheKey);
}
if ($this->containers->isEmpty()) {
return;
}
- $this->applications = $this->server->applications();
- $this->databases = $this->server->databases();
- $this->previews = $this->server->previews();
- // Eager load service applications and databases to avoid N+1 queries
- $this->services = $this->server->services()
- ->with(['applications:id,service_id', 'databases:id,service_id'])
- ->get();
+ $this->applications = $this->loadApplications();
+ $this->databases = $this->loadDatabases();
+ $this->previews = $this->loadPreviews();
+ $this->services = $this->loadServices();
+ $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
+ $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
+ $this->databasesByUuid = $this->databases->keyBy('uuid');
+ $this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
+ $this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
+ $this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
$this->allApplicationIds = $this->applications->filter(function ($application) {
return $application->additional_servers_count === 0;
@@ -163,9 +214,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
});
$this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
- // Use eager-loaded relationships instead of querying in loop
- $this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
- $this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
+ $this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
+ $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
@@ -279,6 +329,151 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->checkLogDrainContainer();
}
+ private function loadApplications(): Collection
+ {
+ [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
+
+ $applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
+ ? Application::withoutGlobalScope('withRelations')
+ ->select([
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'build_pack',
+ 'docker_compose_raw',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ ])
+ ->withCount('additional_servers')
+ ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
+ ->get()
+ : collect();
+
+ $additionalApplicationIds = DB::table('additional_destinations')
+ ->where('server_id', $this->server->id)
+ ->pluck('application_id');
+
+ if ($additionalApplicationIds->isNotEmpty()) {
+ $applications = $applications->concat(
+ Application::withoutGlobalScope('withRelations')
+ ->select([
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'build_pack',
+ 'docker_compose_raw',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ ])
+ ->withCount('additional_servers')
+ ->whereIn('id', $additionalApplicationIds)
+ ->get()
+ );
+ }
+
+ return $applications->unique('id')->values();
+ }
+
+ private function loadPreviews(): Collection
+ {
+ $applicationIds = $this->applications->pluck('id');
+
+ if ($applicationIds->isEmpty()) {
+ return collect();
+ }
+
+ return ApplicationPreview::query()
+ ->select([
+ 'id',
+ 'application_id',
+ 'pull_request_id',
+ 'status',
+ 'last_online_at',
+ ])
+ ->whereIn('application_id', $applicationIds)
+ ->get();
+ }
+
+ private function loadServices(): Collection
+ {
+ return $this->server->services()
+ ->select([
+ 'id',
+ 'server_id',
+ 'uuid',
+ 'docker_compose_raw',
+ ])
+ ->with([
+ 'applications:id,service_id,status,last_online_at',
+ 'databases:id,service_id,status,last_online_at,is_public,name',
+ ])
+ ->get();
+ }
+
+ private function loadDatabases(): Collection
+ {
+ [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
+ if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
+ return collect();
+ }
+ $databaseColumns = [
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'is_public',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ ];
+
+ return collect([
+ StandalonePostgresql::class,
+ StandaloneRedis::class,
+ StandaloneMongodb::class,
+ StandaloneMysql::class,
+ StandaloneMariadb::class,
+ StandaloneKeydb::class,
+ StandaloneDragonfly::class,
+ StandaloneClickhouse::class,
+ ])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
+ return $databaseClass::query()
+ ->select($databaseColumns)
+ ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
+ ->get();
+ })->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
+ }
+
+ private function serverDestinationIds(): array
+ {
+ if ($this->cachedDestinationIds !== null) {
+ return $this->cachedDestinationIds;
+ }
+
+ return $this->cachedDestinationIds = [
+ StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
+ SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
+ ];
+ }
+
+ private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
+ {
+ $query->where(function ($query) use ($standaloneDockerIds) {
+ $query->where('destination_type', StandaloneDocker::class)
+ ->whereIn('destination_id', $standaloneDockerIds);
+ })->orWhere(function ($query) use ($swarmDockerIds) {
+ $query->where('destination_type', SwarmDocker::class)
+ ->whereIn('destination_id', $swarmDockerIds);
+ });
+ }
+
private function aggregateMultiContainerStatuses()
{
if ($this->applicationContainerStatuses->isEmpty()) {
@@ -286,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
- $application = $this->applications->where('id', $applicationId)->first();
+ $application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
continue;
}
@@ -307,8 +502,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
continue;
@@ -323,8 +516,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
}
}
@@ -343,7 +534,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
continue;
}
- $service = $this->services->where('id', $serviceId)->first();
+ $service = $this->servicesById->get((string) $serviceId);
if (! $service) {
continue;
}
@@ -351,9 +542,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
- $subResource = $service->applications->where('id', $subId)->first();
+ $subResource = $this->serviceApplicationsById->get((string) $subId);
} elseif ($subType === 'database') {
- $subResource = $service->databases->where('id', $subId)->first();
+ $subResource = $this->serviceDatabasesById->get((string) $subId);
}
if (! $subResource) {
@@ -375,8 +566,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
- } elseif ($aggregatedStatus) {
- $subResource->update(['last_online_at' => now()]);
}
continue;
@@ -392,39 +581,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
- } elseif ($aggregatedStatus) {
- $subResource->update(['last_online_at' => now()]);
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
- $application = $this->applications->where('id', $applicationId)->first();
+ $application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
- } else {
- $application->update(['last_online_at' => now()]);
}
}
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{
- $application = $this->previews->where('application_id', $applicationId)
- ->where('pull_request_id', $pullRequestId)
- ->first();
+ $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
- } else {
- $application->update(['last_online_at' => now()]);
}
}
@@ -472,9 +653,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$applicationId = $parts[0];
$pullRequestId = $parts[1];
- $applicationPreview = $this->previews->where('application_id', $applicationId)
- ->where('pull_request_id', $pullRequestId)
- ->first();
+ $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id);
@@ -500,11 +679,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
} catch (\Throwable $e) {
}
} else {
- // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
+ // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) {
- Cache::put($proxyCacheKey, true, 600);
+ Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
ConnectProxyToNetworksJob::dispatch($this->server);
}
}
@@ -513,15 +692,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{
- $database = $this->databases->where('uuid', $databaseUuid)->first();
+ $database = $this->databasesByUuid->get($databaseUuid);
if (! $database) {
return;
}
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
- } else {
- $database->update(['last_online_at' => now()]);
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@@ -556,7 +733,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
- $database = $this->databases->where('uuid', $databaseUuid)->first();
+ $database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->update([
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index 71829ea41..e7a21949c 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -6,14 +6,15 @@ use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
+use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
-use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
@@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ private const CHUNK_SIZE = 100;
+
/**
* The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time.
@@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue
*/
public function __construct()
{
- $this->onQueue($this->determineQueue());
- }
-
- private function determineQueue(): string
- {
- $preferredQueue = 'crons';
- $fallbackQueue = 'high';
-
- $configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
-
- return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
+ $this->onQueue(crons_queue());
}
/**
@@ -106,21 +99,11 @@ class ScheduledJobManager implements ShouldQueue
'execution_time' => $this->executionTime->toIso8601String(),
]);
- // Process backups - don't let failures stop task processing
+ // Process scheduled backups and tasks together so neither type starves the other.
try {
- $this->processScheduledBackups();
+ $this->processScheduledBackupsAndTasks();
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]);
- }
-
- // Process tasks - don't let failures stop the job manager
- try {
- $this->processScheduledTasks();
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
+ Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
@@ -151,125 +134,211 @@ class ScheduledJobManager implements ShouldQueue
}
}
- private function processScheduledBackups(): void
+ private function processScheduledBackupsAndTasks(): void
{
- $backups = ScheduledDatabaseBackup::with(['database'])
+ $lastBackupId = 0;
+ $lastTaskId = 0;
+
+ do {
+ $backups = $this->scheduledBackupQuery($lastBackupId)->get();
+ $tasks = $this->scheduledTaskQuery($lastTaskId)->get();
+
+ if ($backups->isNotEmpty()) {
+ $lastBackupId = $backups->last()->id;
+ }
+
+ if ($tasks->isNotEmpty()) {
+ $lastTaskId = $tasks->last()->id;
+ }
+
+ $this->processInterleavedDueSchedules(
+ $this->dueScheduledBackups($backups),
+ $this->dueScheduledTasks($tasks),
+ );
+ } while ($backups->isNotEmpty() || $tasks->isNotEmpty());
+ }
+
+ /**
+ * @param array $dueBackups
+ * @param array $dueTasks
+ */
+ private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
+ {
+ $maxCount = max(count($dueBackups), count($dueTasks));
+
+ for ($index = 0; $index < $maxCount; $index++) {
+ if (isset($dueBackups[$index])) {
+ $this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
+ }
+
+ if (isset($dueTasks[$index])) {
+ $this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
+ }
+ }
+ }
+
+ private function scheduledBackupQuery(int $lastBackupId): Builder
+ {
+ return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true)
- ->get();
+ ->where('id', '>', $lastBackupId)
+ ->orderBy('id')
+ ->limit(self::CHUNK_SIZE);
+ }
+
+ private function scheduledTaskQuery(int $lastTaskId): Builder
+ {
+ return ScheduledTask::with([
+ 'service.destination.server.settings',
+ 'service.destination.server.team.subscription',
+ 'application.destination.server.settings',
+ 'application.destination.server.team.subscription',
+ ])
+ ->where('enabled', true)
+ ->where('id', '>', $lastTaskId)
+ ->orderBy('id')
+ ->limit(self::CHUNK_SIZE);
+ }
+
+ /**
+ * @param iterable $backups
+ * @return array
+ */
+ private function dueScheduledBackups(iterable $backups): array
+ {
+ $dueBackups = [];
foreach ($backups as $backup) {
try {
$server = $backup->server();
- $skipReason = $this->getBackupSkipReason($backup, $server);
- if ($skipReason !== null) {
- $this->skippedCount++;
- $this->logSkip('backup', $skipReason, [
- 'backup_id' => $backup->id,
- 'database_id' => $backup->database_id,
- 'database_type' => $backup->database_type,
- 'team_id' => $backup->team_id ?? null,
- ]);
+
+ if (blank(data_get($backup, 'database')) || blank($server)) {
+ $this->processScheduledBackup($backup, $server);
continue;
}
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
-
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
- $frequency = $backup->frequency;
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
- DatabaseBackupJob::dispatch($backup);
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Backup dispatched', [
- 'backup_id' => $backup->id,
- 'database_id' => $backup->database_id,
- 'database_type' => $backup->database_type,
- 'team_id' => $backup->team_id ?? null,
- 'server_id' => $server->id,
- ]);
+ if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
+ $dueBackups[] = [
+ 'backup' => $backup,
+ 'server' => $server,
+ ];
}
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing backup', [
+ Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
+
+ return $dueBackups;
}
- private function processScheduledTasks(): void
+ /**
+ * @param iterable $tasks
+ * @return array
+ */
+ private function dueScheduledTasks(iterable $tasks): array
{
- $tasks = ScheduledTask::with(['service', 'application'])
- ->where('enabled', true)
- ->get();
+ $dueTasks = [];
foreach ($tasks as $task) {
try {
$server = $task->server();
- // Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
- $criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
- if ($criticalSkip !== null) {
- $this->skippedCount++;
- $this->logSkip('task', $criticalSkip, [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server?->team_id,
- ]);
+ if (blank($server) || (! $task->service && ! $task->application)) {
+ $this->processScheduledTask($task, $server);
continue;
}
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
-
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
+ if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
+ $dueTasks[] = [
+ 'task' => $task,
+ 'server' => $server,
+ ];
}
-
- $frequency = $task->frequency;
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
- continue;
- }
-
- // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
- $runtimeSkip = $this->getTaskRuntimeSkipReason($task);
- if ($runtimeSkip !== null) {
- $this->skippedCount++;
- $this->logSkip('task', $runtimeSkip, [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server->team_id,
- ]);
-
- continue;
- }
-
- ScheduledTaskJob::dispatch($task);
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Task dispatched', [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server->team_id,
- 'server_id' => $server->id,
- ]);
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing task', [
+ Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
}
+
+ return $dueTasks;
+ }
+
+ private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
+ {
+ try {
+ $server = $precheckedServer ?? $backup->server();
+ $skipReason = $this->getBackupSkipReason($backup, $server);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logBackupSkip($backup, $skipReason);
+
+ return;
+ }
+
+ if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
+ DatabaseBackupJob::dispatch($backup);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Backup dispatched', [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ 'server_id' => $server->id,
+ ]);
+ }
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing backup', [
+ 'backup_id' => $backup->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
+ {
+ try {
+ $server = $precheckedServer ?? $task->server();
+ $criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
+ if ($criticalSkip !== null) {
+ $this->skippedCount++;
+ $this->logTaskSkip($task, $criticalSkip, $server);
+
+ return;
+ }
+
+ if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
+ return;
+ }
+
+ $runtimeSkip = $this->getTaskRuntimeSkipReason($task);
+ if ($runtimeSkip !== null) {
+ $this->skippedCount++;
+ $this->logTaskSkip($task, $runtimeSkip, $server);
+
+ return;
+ }
+
+ ScheduledTaskJob::dispatch($task);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Task dispatched', [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server->team_id,
+ 'server_id' => $server->id,
+ ]);
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing task', [
+ 'task_id' => $task->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
@@ -337,71 +406,70 @@ class ScheduledJobManager implements ShouldQueue
private function processDockerCleanups(): void
{
- // Get all servers that need cleanup checks
- $servers = $this->getServersForCleanup();
-
- foreach ($servers as $server) {
- try {
- $skipReason = $this->getDockerCleanupSkipReason($server);
- if ($skipReason !== null) {
- $this->skippedCount++;
- $this->logSkip('docker_cleanup', $skipReason, [
- 'server_id' => $server->id,
- 'server_name' => $server->name,
- 'team_id' => $server->team_id,
- ]);
-
- continue;
+ $this->getServersForCleanupQuery()
+ ->chunkById(self::CHUNK_SIZE, function ($servers): void {
+ foreach ($servers as $server) {
+ $this->processDockerCleanup($server);
}
+ });
+ }
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
- $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- // Use the frozen execution time for consistent evaluation
- if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
- DockerCleanupJob::dispatch(
- $server,
- false,
- $server->settings->delete_unused_volumes,
- $server->settings->delete_unused_networks
- );
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Docker cleanup dispatched', [
- 'server_id' => $server->id,
- 'server_name' => $server->name,
- 'team_id' => $server->team_id,
- ]);
- }
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
+ private function processDockerCleanup(Server $server): void
+ {
+ try {
+ $skipReason = $this->getDockerCleanupSkipReason($server);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
- 'error' => $e->getMessage(),
+ 'team_id' => $server->team_id,
+ ]);
+
+ return;
+ }
+
+ $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
+
+ if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
+ DockerCleanupJob::dispatch(
+ $server,
+ false,
+ $server->settings->delete_unused_volumes,
+ $server->settings->delete_unused_networks
+ );
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Docker cleanup dispatched', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'team_id' => $server->team_id,
]);
}
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'error' => $e->getMessage(),
+ ]);
}
}
- private function getServersForCleanup(): Collection
+ private function getServersForCleanupQuery(): Builder
{
$query = Server::with('settings')
->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
- $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
- $own = Team::find(0)->servers()->with('settings')->get();
-
- return $servers->merge($own);
+ $query
+ ->with('team.subscription')
+ ->where(function (Builder $query): void {
+ $query
+ ->where('team_id', 0)
+ ->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
+ });
}
- return $query->get();
+ return $query;
}
private function getDockerCleanupSkipReason(Server $server): ?string
@@ -428,4 +496,71 @@ class ScheduledJobManager implements ShouldQueue
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
+
+ private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
+ {
+ return shouldRunCronNow(
+ $this->normalizeFrequency($frequency),
+ $this->serverTimezone($server),
+ $dedupKey,
+ $this->executionTime,
+ );
+ }
+
+ private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
+ {
+ $cron = new CronExpression($this->normalizeFrequency($frequency));
+ $executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
+ $lastDispatched = Cache::get($dedupKey);
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+
+ if ($lastDispatched === null) {
+ $isDue = $cron->isDue($executionTime);
+
+ if (! $isDue) {
+ Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
+ }
+
+ return $isDue;
+ }
+
+ $shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
+
+ if (! $shouldFire) {
+ Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
+ }
+
+ return $shouldFire;
+ }
+
+ private function normalizeFrequency(string $frequency): string
+ {
+ return VALID_CRON_STRINGS[$frequency] ?? $frequency;
+ }
+
+ private function serverTimezone(Server $server): string
+ {
+ $timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
+
+ return validate_timezone($timezone) ? $timezone : config('app.timezone');
+ }
+
+ private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
+ {
+ $this->logSkip('backup', $reason, [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ ]);
+ }
+
+ private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
+ {
+ $this->logSkip('task', $reason, [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server?->team_id,
+ ]);
+ }
}
diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php
index 49b9b9702..dc11ec89e 100644
--- a/app/Jobs/ScheduledTaskJob.php
+++ b/app/Jobs/ScheduledTaskJob.php
@@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/
public $timeout = 300;
- public Team $team;
+ public ?Team $team = null;
public ?Server $server = null;
public ScheduledTask $task;
- public Application|Service $resource;
+ public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null;
@@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public array $containers = [];
- public string $server_timezone;
+ public string $server_timezone = 'UTC';
- public function __construct($task)
+ public function __construct(ScheduledTask $task)
{
- $this->onQueue('high');
+ $this->onQueue(crons_queue());
$this->task = $task;
- if ($service = $task->service()->first()) {
- $this->resource = $service;
- } elseif ($application = $task->application()->first()) {
- $this->resource = $application;
+ $this->timeout = $this->task->timeout ?? 300;
+ }
+
+ private function initializeExecutionContext(): void
+ {
+ $this->task->loadMissing([
+ 'service.destination.server.settings',
+ 'application.destination.server.settings',
+ ]);
+
+ if ($this->task->service) {
+ $this->resource = $this->task->service;
+ } elseif ($this->task->application) {
+ $this->resource = $this->task->application;
} else {
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
}
- $this->team = Team::findOrFail($task->team_id);
- $this->server_timezone = $this->getServerTimezone();
- // Set timeout from task configuration
- $this->timeout = $this->task->timeout ?? 300;
+ $this->team = Team::findOrFail($this->task->team_id);
+ $this->server_timezone = $this->getServerTimezone();
+ $this->server = $this->resource->destination->server;
}
private function getServerTimezone(): string
@@ -98,6 +107,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
$startTime = Carbon::now();
try {
+ $this->initializeExecutionContext();
+
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
@@ -107,8 +118,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
- $this->server = $this->resource->destination->server;
-
if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) {
@@ -179,7 +188,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
- ScheduledTaskDone::dispatch($this->team->id);
+ if ($this->team) {
+ ScheduledTaskDone::dispatch($this->team->id);
+ }
+
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
@@ -205,6 +217,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/
public function failed(?\Throwable $exception): void
{
+ $this->team ??= Team::find($this->task->team_id);
+
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php
index 607fda3fe..17517cebb 100644
--- a/app/Jobs/SendWebhookJob.php
+++ b/app/Jobs/SendWebhookJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -9,6 +10,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 +43,20 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
+ $validator = Validator::make(
+ ['webhook_url' => $this->webhookUrl],
+ ['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
+ );
+
+ if ($validator->fails()) {
+ Log::warning('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/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php
index a18d45b9a..10faa7e9b 100644
--- a/app/Jobs/ServerCheckJob.php
+++ b/app/Jobs/ServerCheckJob.php
@@ -15,6 +15,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Log;
class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
@@ -36,11 +37,12 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
public function failed(?\Throwable $exception): void
{
- if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ if ($exception instanceof TimeoutExceededException) {
Log::warning('ServerCheckJob timed out', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
+ $this->server->increment('unreachable_count');
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 2c73ae43e..98ad60fff 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -2,8 +2,11 @@
namespace App\Jobs;
+use App\Events\ServerReachabilityChanged;
+use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
+use App\Services\HetznerService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -11,7 +14,9 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Process;
class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -19,7 +24,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public $tries = 1;
- public $timeout = 30;
+ public $timeout = 15;
public function __construct(
public Server $server,
@@ -28,7 +33,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public function middleware(): array
{
- return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(45)->dontRelease()];
+ return [(new WithoutOverlapping('server-connection-check-'.$this->server->uuid))->expireAfter(25)->dontRelease()];
}
private function disableSshMux(): void
@@ -39,6 +44,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public function handle()
{
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@@ -72,6 +80,7 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
Log::warning('ServerConnectionCheck: Server not reachable', [
'server_id' => $this->server->id,
@@ -79,6 +88,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -90,6 +101,12 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_usable' => $isUsable,
]);
+ if ($this->server->unreachable_count > 0) {
+ $this->server->update(['unreachable_count' => 0]);
+ }
+
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -100,6 +117,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
+
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return;
}
@@ -107,23 +127,48 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public function failed(?\Throwable $exception): void
{
- if ($exception instanceof \Illuminate\Queue\TimeoutExceededException) {
+ if ($exception instanceof TimeoutExceededException) {
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
+ $this->server->increment('unreachable_count');
+
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
+ /**
+ * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
+ * or when a previously-notified server recovers. Skips noise from single transient flaps.
+ */
+ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
+ {
+ if ($isReachable) {
+ if (! $wasReachable || $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+
+ return;
+ }
+
+ if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+ }
+
private function checkHetznerStatus(): void
{
$status = null;
try {
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
@@ -144,15 +189,18 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
private function checkConnection(): bool
{
try {
- // Use instant_remote_process with a simple command
- // This will automatically handle mux, sudo, IPv6, Cloudflare tunnel, etc.
- $output = instant_remote_process_with_timeout(
- ['ls -la /'],
- $this->server,
- false // don't throw error
- );
+ // Single SSH attempt without SshRetryHandler — retries waste time for connectivity checks.
+ // Backoff is managed at the dispatch level via unreachable_count.
+ $commands = ['ls -la /'];
+ if ($this->server->isNonRoot()) {
+ $commands = parseCommandsByLineForSudo(collect($commands), $this->server);
+ }
+ $commandString = implode("\n", $commands);
- return $output !== null;
+ $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $commandString, true);
+ $process = Process::timeout(10)->run($sshCommand);
+
+ return $process->exitCode() === 0;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Connection check failed', [
'server_id' => $this->server->id,
diff --git a/app/Jobs/ServerManagerJob.php b/app/Jobs/ServerManagerJob.php
index 3f748f0ca..9532282cc 100644
--- a/app/Jobs/ServerManagerJob.php
+++ b/app/Jobs/ServerManagerJob.php
@@ -86,6 +86,9 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
return;
}
+ if ($this->shouldSkipDueToBackoff($server)) {
+ return;
+ }
ServerConnectionCheckJob::dispatch($server);
} catch (\Exception $e) {
Log::channel('scheduled-errors')->error('Failed to dispatch ServerConnectionCheck', [
@@ -129,7 +132,9 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
if ($sentinelOutOfSync) {
// Dispatch ServerCheckJob if Sentinel is out of sync
if (shouldRunCronNow($this->checkFrequency, $serverTimezone, "server-check:{$server->id}", $this->executionTime)) {
- ServerCheckJob::dispatch($server);
+ if (! $this->shouldSkipDueToBackoff($server)) {
+ ServerCheckJob::dispatch($server);
+ }
}
}
@@ -165,4 +170,39 @@ class ServerManagerJob implements ShouldBeEncrypted, ShouldQueue
// Note: CheckAndStartSentinelJob is only dispatched daily (line above) for version updates.
// Crash recovery is handled by sentinelOutOfSync → ServerCheckJob → CheckAndStartSentinelJob.
}
+
+ /**
+ * Determine the backoff cycle interval based on how many consecutive times a server has been unreachable.
+ * Higher counts → less frequent checks (based on 5-min cloud cycle):
+ * 0-2: every cycle, 3-5: ~15 min, 6-11: ~30 min, 12+: ~60 min
+ */
+ private function getBackoffCycleInterval(int $unreachableCount): int
+ {
+ return match (true) {
+ $unreachableCount <= 2 => 1,
+ $unreachableCount <= 5 => 3,
+ $unreachableCount <= 11 => 6,
+ default => 12,
+ };
+ }
+
+ /**
+ * Check if a server should be skipped this cycle due to unreachable backoff.
+ * Uses server ID hash to distribute checks across cycles (avoid thundering herd).
+ */
+ private function shouldSkipDueToBackoff(Server $server): bool
+ {
+ $unreachableCount = $server->unreachable_count ?? 0;
+ $interval = $this->getBackoffCycleInterval($unreachableCount);
+
+ if ($interval <= 1) {
+ return false;
+ }
+
+ $cyclePeriodMinutes = isCloud() ? 5 : 1;
+ $cycleIndex = intdiv($this->executionTime->minute, $cyclePeriodMinutes);
+ $serverHash = abs(crc32((string) $server->id));
+
+ return ($cycleIndex + $serverHash) % $interval !== 0;
+ }
}
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index 3485ffe32..b031b9c7d 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
+use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -35,7 +36,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@@ -94,12 +95,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_subscription_id) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@@ -154,7 +155,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
$team = data_get($subscription, 'team');
if (! $team) {
@@ -165,7 +166,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@@ -190,7 +191,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_invoice_paid) {
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
@@ -334,7 +335,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
break;
default:
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/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php
index f37a9704e..060ec3ac6 100644
--- a/app/Jobs/VolumeCloneJob.php
+++ b/app/Jobs/VolumeCloneJob.php
@@ -43,27 +43,34 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
protected function cloneLocalVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
+
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer);
}
protected function cloneRemoteVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
+ $srcDir = escapeshellarg($sourceCloneDir);
+ $tgtDir = escapeshellarg($targetCloneDir);
try {
instant_remote_process([
- "mkdir -p $sourceCloneDir",
- "chmod 777 $sourceCloneDir",
- "docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
+ "mkdir -p {$srcDir}",
+ "chmod 777 {$srcDir}",
+ "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer);
instant_remote_process([
- "mkdir -p $targetCloneDir",
- "chmod 777 $targetCloneDir",
+ "mkdir -p {$tgtDir}",
+ "chmod 777 {$tgtDir}",
], $this->targetServer);
instant_scp(
@@ -74,8 +81,8 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
);
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer);
} catch (\Exception $e) {
@@ -84,7 +91,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
} finally {
try {
instant_remote_process([
- "rm -rf $sourceCloneDir",
+ "rm -rf {$srcDir}",
], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
@@ -93,7 +100,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
try {
if ($this->targetServer) {
instant_remote_process([
- "rm -rf $targetCloneDir",
+ "rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
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..4d22047cc 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);
@@ -39,12 +37,13 @@ class Index extends Component
Auth::login($user);
refreshSession($team_to_switch_to);
- return redirect(request()->header('Referer'));
+ return redirect()->route('admin.index');
}
}
public function submitSearch()
{
+ $this->authorizeAdminAccess();
if ($this->search !== '') {
$this->foundUsers = User::where(function ($query) {
$query->where('name', 'like', "%{$this->search}%")
@@ -61,17 +60,31 @@ 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'));
+ return redirect()->route('dashboard');
+ }
+
+ 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()
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/Index.php b/app/Livewire/Destination/Index.php
index a3df3fd56..7a4b89fab 100644
--- a/app/Livewire/Destination/Index.php
+++ b/app/Livewire/Destination/Index.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\Server;
+use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -11,9 +12,15 @@ class Index extends Component
#[Locked]
public $servers;
- public function mount()
+ #[Locked]
+ public Collection $destinations;
+
+ public function mount(): void
{
$this->servers = Server::isUsable()->get();
+ $this->destinations = $this->servers
+ ->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
+ ->values();
}
public function render()
diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php
index 70751fa03..254823163 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'])]
@@ -33,44 +33,49 @@ class Docker extends Component
#[Validate(['required', 'boolean'])]
public bool $isSwarm = false;
- public function mount(?string $server_id = null)
+ public function mount(?string $server_id = null): void
{
- $this->network = new Cuid2;
+ $this->network = (string) new Cuid2;
$this->servers = Server::isUsable()->get();
- if ($server_id) {
- $foundServer = $this->servers->find($server_id) ?: $this->servers->first();
- if (! $foundServer) {
- throw new \Exception('Server not found.');
+
+ if (filled($server_id)) {
+ $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
+
+ if (! $this->servers->contains('id', $this->selectedServer->id)) {
+ $this->servers->push($this->selectedServer);
}
- $this->selectedServer = $foundServer;
- $this->serverId = $this->selectedServer->id;
+
+ $this->serverId = (string) $this->selectedServer->id;
} else {
$foundServer = $this->servers->first();
if (! $foundServer) {
throw new \Exception('Server not found.');
}
$this->selectedServer = $foundServer;
- $this->serverId = $this->selectedServer->id;
+ $this->serverId = (string) $this->selectedServer->id;
}
$this->generateName();
}
- public function updatedServerId()
+ public function updatedServerId(): void
{
$this->selectedServer = $this->servers->find($this->serverId);
+ if (! $this->selectedServer) {
+ throw new \Exception('Server not found.');
+ }
$this->generateName();
}
- public function generateName()
+ public function generateName(): void
{
$name = data_get($this->selectedServer, 'name', new Cuid2);
$this->name = str("{$name}-{$this->network}")->kebab();
}
- public function submit()
+ public function submit(): mixed
{
try {
- $this->authorize('create', StandaloneDocker::class);
+ $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
$this->validate();
if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php
index 98cf72376..9d55d7462 100644
--- a/app/Livewire/Destination/Show.php
+++ b/app/Livewire/Destination/Show.php
@@ -2,9 +2,7 @@
namespace App\Livewire\Destination;
-use App\Models\Server;
use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -20,7 +18,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'])]
@@ -29,16 +27,8 @@ class Show extends Component
public function mount(string $destination_uuid)
{
try {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
- SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
-
- $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
- if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
- $this->destination = $destination;
- $this->syncData();
- }
- });
- if ($ownedByTeam === false) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
return redirect()->route('destination.index');
}
$this->destination = $destination;
@@ -80,12 +70,13 @@ class Show extends Component
try {
$this->authorize('delete', $this->destination);
- if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
+ if ($this->destination->getMorphClass() === StandaloneDocker::class) {
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..2463c68e4 100644
--- a/app/Livewire/ForcePasswordReset.php
+++ b/app/Livewire/ForcePasswordReset.php
@@ -47,14 +47,10 @@ class ForcePasswordReset extends Component
try {
$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();
- if ($firstLogin) {
- send_internal_notification('First login for '.auth()->user()->email);
- }
return redirect()->route('dashboard');
} catch (\Throwable $e) {
diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index f910110dc..df2adf22b 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;
@@ -1496,7 +1496,10 @@ class GlobalSearch extends Component
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
- ]);
+ ] + array_filter([
+ 'amd_only' => data_get($service, 'amd_only') ? true : null,
+ 'arm_only' => data_get($service, 'arm_only') ? true : null,
+ ]));
}
$cachedServices = $items->toArray();
diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php
index 490515875..421e50bcc 100644
--- a/app/Livewire/Help.php
+++ b/app/Livewire/Help.php
@@ -15,7 +15,7 @@ class Help extends Component
#[Validate(['required', 'min:10', 'max:1000'])]
public string $description;
- #[Validate(['required', 'min:3'])]
+ #[Validate(['required', 'min:3', 'max:600'])]
public string $subject;
public function submit()
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..724dd0bac 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -42,10 +42,10 @@ 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;
+ public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])]
public ?string $smtpUsername = 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/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php
new file mode 100644
index 000000000..6a1b72f80
--- /dev/null
+++ b/app/Livewire/Profile/Appearance.php
@@ -0,0 +1,13 @@
+injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
+
+ // Load stop_grace_period separately since it has its own save handler
+ // Convert null to empty string to prevent dirty detection issues
+ $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
}
private function resetDefaultLabels()
@@ -210,6 +219,7 @@ class Advanced extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Settings saved.');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -228,6 +238,7 @@ class Advanced extends Component
if (is_null($this->customInternalName)) {
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
return;
}
@@ -247,6 +258,32 @@ class Advanced extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function saveStopGracePeriod()
+ {
+ try {
+ $this->authorize('update', $this->application);
+
+ $validated = Validator::make(
+ ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
+ ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
+ [],
+ ['stopGracePeriod' => 'stop grace period']
+ )->validate();
+
+ $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
+ ? null
+ : (int) $validated['stopGracePeriod'];
+ $this->application->settings->save();
+
+ $this->dispatch('success', 'Stop grace period updated.');
+ } catch (ValidationException $e) {
+ throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php
index cc1bf15b9..fb069f65b 100644
--- a/app/Livewire/Project/Application/Configuration.php
+++ b/app/Livewire/Project/Application/Configuration.php
@@ -17,17 +17,10 @@ class Configuration extends Component
public $servers;
- public function getListeners()
- {
- $teamId = auth()->user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
- "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
- 'buildPackUpdated' => '$refresh',
- 'refresh' => '$refresh',
- ];
- }
+ protected $listeners = [
+ 'buildPackUpdated' => '$refresh',
+ 'refresh' => '$refresh',
+ ];
public function mount()
{
@@ -35,7 +28,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -51,8 +44,6 @@ class Configuration extends Component
$this->environment = $environment;
$this->application = $application;
-
-
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index 954670582..c9f818e2c 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -108,19 +108,6 @@ class Show extends Component
return decode_remote_command_output($this->application_deployment_queue);
}
- public function copyLogs(): string
- {
- $logs = decode_remote_command_output($this->application_deployment_queue)
- ->map(function ($line) {
- return $line['timestamp'].' '.
- (isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
- trim($line['line']);
- })
- ->join("\n");
-
- return sanitizeLogsForExport($logs);
- }
-
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 885c9dbac..89b1b4217 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
+use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -144,21 +145,21 @@ class General extends Component
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'gitRepository' => 'required',
- 'gitBranch' => 'required',
+ 'gitBranch' => ['required', 'string', new ValidGitBranch],
'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' => 'nullable',
- 'portsMappings' => 'nullable',
+ 'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
+ 'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
- 'dockerRegistryImageName' => 'nullable',
- 'dockerRegistryImageTag' => 'nullable',
+ 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
+ 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
@@ -197,9 +198,12 @@ class General extends Component
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
- 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'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.',
@@ -208,6 +212,8 @@ class General extends Component
'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory 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.',
@@ -600,7 +606,7 @@ class General extends Component
// Sync property to model before checking/modifying
$this->syncData(toModel: true);
- if ($this->buildPack !== 'nixpacks') {
+ if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
@@ -731,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).');
@@ -751,6 +758,12 @@ class General extends Component
$this->authorize('update', $this->application);
$this->resetErrorBag();
+
+ $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
+ if ($this->portsMappings) {
+ $this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
+ }
+
$this->validate();
$oldPortsExposes = $this->application->ports_exposes;
@@ -835,7 +848,7 @@ class General extends Component
}
if ($this->buildPack === 'dockerimage') {
$this->validate([
- 'dockerRegistryImageName' => 'required',
+ 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
]);
}
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index 41f352c14..59b52f557 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,13 +309,40 @@ 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();
+ $timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php
new file mode 100644
index 000000000..459271e28
--- /dev/null
+++ b/app/Livewire/Project/Application/ServerStatusBadge.php
@@ -0,0 +1,41 @@
+currentTeam();
+ if (! $team) {
+ return [];
+ }
+
+ return [
+ "echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
+ "echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
+ ];
+ }
+
+ public function refreshStatus(): void
+ {
+ $this->application->refresh();
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.application.server-status-badge');
+ }
+}
diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php
index 422dd6b28..3ee5919fe 100644
--- a/app/Livewire/Project/Application/Source.php
+++ b/app/Livewire/Project/Application/Source.php
@@ -3,7 +3,10 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
+use App\Models\GithubApp;
+use App\Models\GitlabApp;
use App\Models\PrivateKey;
+use App\Rules\ValidGitBranch;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -21,13 +24,13 @@ class Source extends Component
#[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null;
- #[Validate(['nullable', 'integer'])]
+ #[Locked]
public ?int $privateKeyId = null;
#[Validate(['required', 'string'])]
public string $gitRepository;
- #[Validate(['required', 'string'])]
+ #[Validate(['required', 'string', new ValidGitBranch])]
public string $gitBranch;
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
@@ -103,12 +106,14 @@ class Source extends Component
{
try {
$this->authorize('update', $this->application);
- $this->privateKeyId = $privateKeyId;
+ $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
+ $this->privateKeyId = $key->id;
$this->syncData(true);
$this->getPrivateKeys();
$this->application->refresh();
$this->privateKeyName = $this->application->private_key->name;
$this->dispatch('success', 'Private key updated!');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -124,6 +129,7 @@ class Source extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Application source updated!');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -134,8 +140,11 @@ class Source extends Component
try {
$this->authorize('update', $this->application);
+ $allowedSourceTypes = [GithubApp::class, GitlabApp::class];
+ abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
+ $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
$this->application->update([
- 'source_id' => $sourceId,
+ 'source_id' => $source->id,
'source_type' => $sourceType,
]);
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..ef106a65f 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
+use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
@@ -76,7 +77,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()
{
@@ -144,7 +145,7 @@ class BackupEdit extends Component
try {
$server = null;
- if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
+ if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server;
@@ -170,7 +171,7 @@ class BackupEdit extends Component
$this->backup->delete();
- if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [
@@ -182,7 +183,7 @@ class BackupEdit extends Component
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this);
@@ -207,6 +208,13 @@ class BackupEdit extends Component
$this->backup->s3_storage_id = null;
}
+ // S3 backup cannot be enabled without a valid S3 storage owned by the team
+ $availableS3Ids = collect($this->s3s)->pluck('id');
+ if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
+ $this->backup->save_s3 = $this->saveS3 = false;
+ $this->backup->s3_storage_id = $this->s3StorageId = null;
+ }
+
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
@@ -214,7 +222,7 @@ class BackupEdit extends Component
$isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) {
- throw new \Exception('Invalid Cron / Human expression');
+ throw new Exception('Invalid Cron / Human expression');
}
$this->validate();
}
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index 9de75c1c5..694674326 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -34,24 +34,27 @@ 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;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public function getListeners()
+ public function getListeners(): array
{
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -76,16 +79,18 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'clickhouseAdminUser' => 'required|string',
- 'clickhouseAdminPassword' => 'required|string',
+ 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
+ ),
+ 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
+ ),
'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',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
];
}
@@ -94,14 +99,15 @@ 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.',
- 'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
- 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
+ ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'),
+ ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'),
'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,14 +125,11 @@ 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();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -139,8 +142,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -189,6 +190,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -197,9 +199,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -207,11 +213,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php
new file mode 100644
index 000000000..51a3192fa
--- /dev/null
+++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php
@@ -0,0 +1,31 @@
+currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
- ];
- }
-
public function mount()
{
try {
@@ -34,7 +26,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -55,10 +47,10 @@ class Configuration extends Component
$this->dispatch('configurationChanged');
}
} catch (\Throwable $e) {
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard');
}
- if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
+ if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard');
}
diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php
index 7f807afe2..7384adcff 100644
--- a/app/Livewire/Project/Database/CreateScheduledBackup.php
+++ b/app/Livewire/Project/Database/CreateScheduledBackup.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database;
+use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
+use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
@@ -48,6 +50,20 @@ class CreateScheduledBackup extends Component
$this->validate();
+ if ($this->saveToS3) {
+ $s3StorageExists = ! is_null($this->s3StorageId)
+ && S3Storage::where('team_id', currentTeam()->id)
+ ->where('is_usable', true)
+ ->whereKey($this->s3StorageId)
+ ->exists();
+
+ if (! $s3StorageExists) {
+ $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
+
+ return;
+ }
+ }
+
$isValid = validate_cron_expression($this->frequency);
if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.');
@@ -74,7 +90,7 @@ class CreateScheduledBackup extends Component
}
$databaseBackup = ScheduledDatabaseBackup::create($payload);
- if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else {
$this->dispatch('refreshScheduledBackups');
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index d35e57a9d..f196b9dfb 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -34,30 +32,27 @@ 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;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public ?Carbon $certificateValidUntil = null;
-
- public bool $enable_ssl = false;
-
- public function getListeners()
+ public function getListeners(): array
{
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -72,12 +67,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -88,17 +77,16 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'dragonflyPassword' => 'required|string',
+ 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
+ ),
'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',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
- 'enable_ssl' => 'nullable|boolean',
];
}
@@ -106,12 +94,14 @@ 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.',
+ ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'),
'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,15 +118,11 @@ 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;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -148,9 +134,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->enable_ssl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -199,6 +182,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -207,9 +191,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -217,11 +205,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -233,64 +225,9 @@ class General extends Component
}
}
- public function instantSaveSSL()
+ public function refresh(): void
{
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $server = $this->database->destination->server;
-
- $caCert = $server->sslCertificates()
- ->where('is_ca_certificate', true)
- ->first();
-
- if (! $caCert) {
- $server->generateCaCertificate();
- $caCert = $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 ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
+ $this->database->refresh();
+ $this->syncData();
}
}
diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php
new file mode 100644
index 000000000..baeb3d09f
--- /dev/null
+++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php
@@ -0,0 +1,26 @@
+authorize('view', $this->database);
+ $this->syncData();
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+ $this->database->health_check_enabled = $this->healthCheckEnabled;
+ $this->database->health_check_interval = $this->healthCheckInterval;
+ $this->database->health_check_timeout = $this->healthCheckTimeout;
+ $this->database->health_check_retries = $this->healthCheckRetries;
+ $this->database->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->database->save();
+ } else {
+ $this->healthCheckEnabled = $this->database->health_check_enabled;
+ $this->healthCheckInterval = $this->database->health_check_interval;
+ $this->healthCheckTimeout = $this->database->health_check_timeout;
+ $this->healthCheckRetries = $this->database->health_check_retries;
+ $this->healthCheckStartPeriod = $this->database->health_check_start_period;
+ }
+ }
+
+ public function instantSave(): void
+ {
+ $this->submit();
+ }
+
+ public function submit(): void
+ {
+ $updateSuccessful = false;
+
+ try {
+ $this->authorize('update', $this->database);
+ $this->syncData(true);
+ $updateSuccessful = true;
+ $this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+
+ if (! $updateSuccessful) {
+ return;
+ }
+
+ $this->markConfigurationChanged();
+ }
+
+ public function toggleHealthcheck(): void
+ {
+ $updateSuccessful = false;
+
+ try {
+ $this->authorize('update', $this->database);
+ $this->healthCheckEnabled = ! $this->healthCheckEnabled;
+ $this->syncData(true);
+ $updateSuccessful = true;
+ $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+
+ if (! $updateSuccessful) {
+ return;
+ }
+
+ $this->markConfigurationChanged();
+ }
+
+ private function markConfigurationChanged(): void
+ {
+ if (is_null($this->database->config_hash)) {
+ $this->database->isConfigurationChanged(true);
+
+ return;
+ }
+
+ $this->dispatch('configurationChanged');
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.database.health');
+ }
+}
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 1cdc681cd..ea04658cf 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -2,14 +2,14 @@
namespace App\Livewire\Project\Database;
-use App\Models\S3Storage;
-use App\Models\Server;
-use App\Models\Service;
-use App\Support\ValidationPatterns;
+use App\Models\ServiceDatabase;
+use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDragonfly;
+use App\Models\StandaloneKeydb;
+use App\Models\StandaloneRedis;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Storage;
-use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -17,797 +17,134 @@ class Import extends Component
{
use AuthorizesRequests;
- /**
- * Validate that a string is safe for use as an S3 bucket name.
- * Allows alphanumerics, dots, dashes, and underscores.
- */
- private function validateBucketName(string $bucket): bool
- {
- return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
- }
-
- /**
- * Validate that a string is safe for use as an S3 path.
- * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
- */
- private function validateS3Path(string $path): bool
- {
- // Must not be empty
- if (empty($path)) {
- return false;
- }
-
- // Must not contain dangerous shell metacharacters or command injection patterns
- $dangerousPatterns = [
- '..', // Directory traversal
- '$(', // Command substitution
- '`', // Backtick command substitution
- '|', // Pipe
- ';', // Command separator
- '&', // Background/AND
- '>', // Redirect
- '<', // Redirect
- "\n", // Newline
- "\r", // Carriage return
- "\0", // Null byte
- "'", // Single quote
- '"', // Double quote
- '\\', // Backslash
- ];
-
- foreach ($dangerousPatterns as $pattern) {
- if (str_contains($path, $pattern)) {
- return false;
- }
- }
-
- // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
- return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
- }
-
- /**
- * Validate that a string is safe for use as a file path on the server.
- */
- private function validateServerPath(string $path): bool
- {
- // Must be an absolute path
- if (! str_starts_with($path, '/')) {
- return false;
- }
-
- // Must not contain dangerous shell metacharacters or command injection patterns
- $dangerousPatterns = [
- '..', // Directory traversal
- '$(', // Command substitution
- '`', // Backtick command substitution
- '|', // Pipe
- ';', // Command separator
- '&', // Background/AND
- '>', // Redirect
- '<', // Redirect
- "\n", // Newline
- "\r", // Carriage return
- "\0", // Null byte
- "'", // Single quote
- '"', // Double quote
- '\\', // Backslash
- ];
-
- foreach ($dangerousPatterns as $pattern) {
- if (str_contains($path, $pattern)) {
- return false;
- }
- }
-
- // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
- return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
- }
-
- public bool $unsupported = false;
-
- // Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
- #[Locked]
- public ?int $serverId = null;
-
- // View-friendly properties to avoid computed property access in Blade
- #[Locked]
- public string $resourceUuid = '';
-
public string $resourceStatus = '';
- #[Locked]
- public string $resourceDbType = '';
+ public string $resourceUuid = '';
- public array $parameters = [];
+ public bool $unsupported = false;
- public array $containers = [];
-
- public bool $scpInProgress = false;
-
- public bool $importRunning = false;
-
- public ?string $filename = null;
-
- public ?string $filesize = null;
-
- public bool $isUploading = false;
-
- public int $progress = 0;
-
- public bool $error = false;
-
- #[Locked]
- public string $container;
-
- public array $importCommands = [];
-
- public bool $dumpAll = false;
-
- public string $restoreCommandText = '';
-
- public string $customLocation = '';
-
- public ?int $activityId = null;
-
- public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
-
- public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
-
- public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
-
- public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
-
- // S3 Restore properties
- public array $availableS3Storages = [];
-
- public ?int $s3StorageId = null;
-
- public string $s3Path = '';
-
- public ?int $s3FileSize = null;
-
- #[Computed]
- public function resource()
+ public function getListeners(): array
{
- if ($this->resourceId === null || $this->resourceType === null) {
- return null;
+ $listeners = ['databaseUpdated' => 'refreshStatus'];
+
+ $user = Auth::user();
+ if (! $user) {
+ return $listeners;
}
- return $this->resourceType::find($this->resourceId);
- }
+ $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
- #[Computed]
- public function server()
- {
- if ($this->serverId === null) {
- return null;
+ $team = $user->currentTeam();
+ if ($team) {
+ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
}
- return Server::ownedByCurrentTeam()->find($this->serverId);
+ return $listeners;
}
- public function getListeners()
+ public function mount(): void
{
- $userId = Auth::id();
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
- 'slideOverClosed' => 'resetActivityId',
- ];
- }
-
- public function resetActivityId()
- {
- $this->activityId = null;
- }
-
- public function mount()
- {
- $this->parameters = get_route_parameters();
- $this->getContainers();
- $this->loadAvailableS3Storages();
- }
-
- public function updatedDumpAll($value)
- {
- $morphClass = $this->resource->getMorphClass();
-
- // Handle ServiceDatabase by checking the database type
- if ($morphClass === \App\Models\ServiceDatabase::class) {
- $dbType = $this->resource->databaseType();
- if (str_contains($dbType, 'mysql')) {
- $morphClass = 'mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $morphClass = 'mariadb';
- } elseif (str_contains($dbType, 'postgres')) {
- $morphClass = 'postgresql';
- }
- }
-
- switch ($morphClass) {
- case \App\Models\StandaloneMariadb::class:
- case 'mariadb':
- if ($value === true) {
- $this->mariadbRestoreCommand = <<<'EOD'
-for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
- mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
-done && \
-mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
-mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
-(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
-EOD;
- $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
- } else {
- $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
- }
- break;
- case \App\Models\StandaloneMysql::class:
- case 'mysql':
- if ($value === true) {
- $this->mysqlRestoreCommand = <<<'EOD'
-for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
- mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
-done && \
-mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
-mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
-(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
-EOD;
- $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
- } else {
- $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
- }
- break;
- case \App\Models\StandalonePostgresql::class:
- case 'postgresql':
- if ($value === true) {
- $this->postgresqlRestoreCommand = <<<'EOD'
-psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
-psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
-createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
-EOD;
- $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
- } else {
- $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
- }
- break;
- }
-
- }
-
- public function getContainers()
- {
- $this->containers = [];
- $teamId = data_get(auth()->user()->currentTeam(), 'id');
-
- // Try to find resource by route parameter
- $databaseUuid = data_get($this->parameters, 'database_uuid');
- $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
-
- $resource = null;
- if ($databaseUuid) {
- // Standalone database route
- $resource = getResourceByUuid($databaseUuid, $teamId);
- if (is_null($resource)) {
- abort(404);
- }
- } elseif ($stackServiceUuid) {
- // ServiceDatabase route - look up the service database
- $serviceUuid = data_get($this->parameters, 'service_uuid');
- $service = Service::whereUuid($serviceUuid)->first();
- if (! $service) {
- abort(404);
- }
- $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
- if (is_null($resource)) {
- abort(404);
- }
- } else {
- abort(404);
- }
-
+ $resource = $this->resolveResourceFromRoute();
$this->authorize('view', $resource);
- // Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
- // Store view-friendly properties
+ $this->refreshStatus();
+ }
+
+ public function refreshStatus(): void
+ {
+ $resource = $this->resolveStoredResource();
+ $this->authorize('view', $resource);
+
+ $resource->refresh();
+ $this->resourceUuid = $resource->uuid;
$this->resourceStatus = $resource->status ?? '';
+ $this->unsupported = $this->isUnsupportedResource($resource);
+ }
- // Handle ServiceDatabase server access differently
- if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
- $server = $resource->service?->server;
- if (! $server) {
- abort(404, 'Server not found for this service database.');
- }
- $this->serverId = $server->id;
- $this->container = $resource->name.'-'.$resource->service->uuid;
- $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
+ public function render(): View
+ {
+ return view('livewire.project.database.import');
+ }
- // Determine database type for ServiceDatabase
- $dbType = $resource->databaseType();
- if (str_contains($dbType, 'postgres')) {
- $this->resourceDbType = 'standalone-postgresql';
- } elseif (str_contains($dbType, 'mysql')) {
- $this->resourceDbType = 'standalone-mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $this->resourceDbType = 'standalone-mariadb';
- } elseif (str_contains($dbType, 'mongo')) {
- $this->resourceDbType = 'standalone-mongodb';
- } else {
- $this->resourceDbType = $dbType;
+ private function resolveResourceFromRoute(): object
+ {
+ $parameters = get_route_parameters();
+ $teamId = data_get(Auth::user()?->currentTeam(), 'id');
+ $databaseUuid = data_get($parameters, 'database_uuid');
+ $stackServiceUuid = data_get($parameters, 'stack_service_uuid');
+
+ if ($databaseUuid) {
+ $resource = getResourceByUuid($databaseUuid, $teamId);
+ if ($resource) {
+ return $resource;
}
- } else {
- $server = $resource->destination?->server;
- if (! $server) {
- abort(404, 'Server not found for this database.');
- }
- $this->serverId = $server->id;
- $this->container = $resource->uuid;
- $this->resourceUuid = $resource->uuid;
- $this->resourceDbType = $resource->type();
+
+ abort(404);
}
- if (str($resource->status)->startsWith('running')) {
- $this->containers[] = $this->container;
+ if ($stackServiceUuid) {
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', data_get($parameters, 'project_uuid'))
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', data_get($parameters, 'environment_uuid'))
+ ->firstOrFail();
+ $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
+ $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
+ if ($resource) {
+ return $resource;
+ }
}
+ abort(404);
+ }
+
+ private function resolveStoredResource(): object
+ {
+ if ($this->resourceId === null || $this->resourceType === null) {
+ return $this->resolveResourceFromRoute();
+ }
+
+ $resource = $this->resourceType::find($this->resourceId);
+ if ($resource) {
+ return $resource;
+ }
+
+ abort(404);
+ }
+
+ private function isUnsupportedResource(object $resource): bool
+ {
if (
- $resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
- $resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
- $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
- $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
+ $resource instanceof StandaloneRedis ||
+ $resource instanceof StandaloneKeydb ||
+ $resource instanceof StandaloneDragonfly ||
+ $resource instanceof StandaloneClickhouse
) {
- $this->unsupported = true;
+ return true;
}
- // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
- if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($resource instanceof ServiceDatabase) {
$dbType = $resource->databaseType();
- if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
- str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
- $this->unsupported = true;
- }
- }
- }
- public function checkFile()
- {
- if (filled($this->customLocation)) {
- // Validate the custom location to prevent command injection
- if (! $this->validateServerPath($this->customLocation)) {
- $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return;
- }
-
- try {
- $escapedPath = escapeshellarg($this->customLocation);
- $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
- if (blank($result)) {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
-
- return;
- }
- $this->filename = $this->customLocation;
- $this->dispatch('success', 'The file exists.');
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
- }
-
- public function runImport(string $password = ''): bool|string
- {
- if (! verifyPasswordConfirmation($password, $this)) {
- return 'The provided password is incorrect.';
+ return str_contains($dbType, 'redis') ||
+ str_contains($dbType, 'keydb') ||
+ str_contains($dbType, 'dragonfly') ||
+ str_contains($dbType, 'clickhouse');
}
- $this->authorize('update', $this->resource);
-
- if (! ValidationPatterns::isValidContainerName($this->container)) {
- $this->dispatch('error', 'Invalid container name.');
-
- return true;
- }
-
- if ($this->filename === '') {
- $this->dispatch('error', 'Please select a file to import.');
-
- return true;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return true;
- }
-
- try {
- $this->importRunning = true;
- $this->importCommands = [];
- $backupFileName = "upload/{$this->resourceUuid}/restore";
-
- // Check if an uploaded file exists first (takes priority over custom location)
- if (Storage::exists($backupFileName)) {
- $path = Storage::path($backupFileName);
- $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
- instant_scp($path, $tmpPath, $this->server);
- Storage::delete($backupFileName);
- $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
- } elseif (filled($this->customLocation)) {
- // Validate the custom location to prevent command injection
- if (! $this->validateServerPath($this->customLocation)) {
- $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
-
- return true;
- }
- $tmpPath = '/tmp/restore_'.$this->resourceUuid;
- $escapedCustomLocation = escapeshellarg($this->customLocation);
- $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
- } else {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
-
- return true;
- }
-
- // Copy the restore command to a script file
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- $restoreCommand = $this->buildRestoreCommand($tmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
- $this->importCommands[] = "chmod +x {$scriptPath}";
- $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
-
- $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
- $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- if (! empty($this->importCommands)) {
- $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
- 'scriptPath' => $scriptPath,
- 'tmpPath' => $tmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
- $this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
- $this->dispatch('activityMonitor', $activity->id);
- $this->dispatch('databaserestore');
- }
- } catch (\Throwable $e) {
- handleError($e, $this);
-
- return true;
- } finally {
- $this->filename = null;
- $this->importCommands = [];
- }
-
- return true;
- }
-
- public function loadAvailableS3Storages()
- {
- try {
- $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
- ->where('is_usable', true)
- ->get()
- ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
- ->toArray();
- } catch (\Throwable $e) {
- $this->availableS3Storages = [];
- }
- }
-
- public function updatedS3Path($value)
- {
- // Reset validation state when path changes
- $this->s3FileSize = null;
-
- // Ensure path starts with a slash
- if ($value !== null && $value !== '') {
- $this->s3Path = str($value)->trim()->start('/')->value();
- }
- }
-
- public function updatedS3StorageId()
- {
- // Reset validation state when storage changes
- $this->s3FileSize = null;
- }
-
- public function checkS3File()
- {
- if (! $this->s3StorageId) {
- $this->dispatch('error', 'Please select an S3 storage.');
-
- return;
- }
-
- if (blank($this->s3Path)) {
- $this->dispatch('error', 'Please provide an S3 path.');
-
- return;
- }
-
- // Clean the path (remove leading slash if present)
- $cleanPath = ltrim($this->s3Path, '/');
-
- // Validate the S3 path early to prevent command injection in subsequent operations
- if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return;
- }
-
- try {
- $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
-
- // Validate bucket name early
- if (! $this->validateBucketName($s3Storage->bucket)) {
- $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
-
- return;
- }
-
- // Test connection
- $s3Storage->testConnection();
-
- // Build S3 disk configuration
- $disk = Storage::build([
- 'driver' => 's3',
- 'region' => $s3Storage->region,
- 'key' => $s3Storage->key,
- 'secret' => $s3Storage->secret,
- 'bucket' => $s3Storage->bucket,
- 'endpoint' => $s3Storage->endpoint,
- 'use_path_style_endpoint' => true,
- ]);
-
- // Check if file exists
- if (! $disk->exists($cleanPath)) {
- $this->dispatch('error', 'File not found in S3. Please check the path.');
-
- return;
- }
-
- // Get file size
- $this->s3FileSize = $disk->size($cleanPath);
-
- $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
- } catch (\Throwable $e) {
- $this->s3FileSize = null;
-
- return handleError($e, $this);
- }
- }
-
- public function restoreFromS3(string $password = ''): bool|string
- {
- if (! verifyPasswordConfirmation($password, $this)) {
- return 'The provided password is incorrect.';
- }
-
- $this->authorize('update', $this->resource);
-
- if (! ValidationPatterns::isValidContainerName($this->container)) {
- $this->dispatch('error', 'Invalid container name.');
-
- return true;
- }
-
- if (! $this->s3StorageId || blank($this->s3Path)) {
- $this->dispatch('error', 'Please select S3 storage and provide a path first.');
-
- return true;
- }
-
- if (is_null($this->s3FileSize)) {
- $this->dispatch('error', 'Please check the file first by clicking "Check File".');
-
- return true;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return true;
- }
-
- try {
- $this->importRunning = true;
-
- $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
-
- $key = $s3Storage->key;
- $secret = $s3Storage->secret;
- $bucket = $s3Storage->bucket;
- $endpoint = $s3Storage->endpoint;
-
- // Validate bucket name to prevent command injection
- if (! $this->validateBucketName($bucket)) {
- $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
-
- return true;
- }
-
- // Clean the S3 path
- $cleanPath = ltrim($this->s3Path, '/');
-
- // Validate the S3 path to prevent command injection
- if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return true;
- }
-
- // Get helper image
- $helperImage = config('constants.coolify.helper_image');
- $latestVersion = getHelperVersion();
- $fullImageName = "{$helperImage}:{$latestVersion}";
-
- // Get the database destination network
- if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
- $destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
- } else {
- $destinationNetwork = $this->resource->destination->network ?? 'coolify';
- }
-
- // Generate unique names for this operation
- $containerName = "s3-restore-{$this->resourceUuid}";
- $helperTmpPath = '/tmp/'.basename($cleanPath);
- $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
- $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- // Prepare all commands in sequence
- $commands = [];
-
- // 1. Clean up any existing helper container and temp files from previous runs
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
- $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
-
- // 2. Start helper container on the database network
- $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
-
- // 3. Configure S3 access in helper container
- $escapedEndpoint = escapeshellarg($endpoint);
- $escapedKey = escapeshellarg($key);
- $escapedSecret = escapeshellarg($secret);
- $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
-
- // 4. Check file exists in S3 (bucket and path already validated above)
- $escapedBucket = escapeshellarg($bucket);
- $escapedCleanPath = escapeshellarg($cleanPath);
- $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
- $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
-
- // 5. Download from S3 to helper container (progress shown by default)
- $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
- $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
-
- // 6. Copy from helper to server, then immediately to database container
- $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
- $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
-
- // 7. Cleanup helper container and server temp file immediately (no longer needed)
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
-
- // 8. Build and execute restore command inside database container
- $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
- $commands[] = "chmod +x {$scriptPath}";
- $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
-
- // 9. Execute restore and cleanup temp files immediately after completion
- $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
- $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- // Execute all commands with cleanup event (as safety net for edge cases)
- $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
- 'containerName' => $containerName,
- 'serverTmpPath' => $serverTmpPath,
- 'scriptPath' => $scriptPath,
- 'containerTmpPath' => $containerTmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
- $this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
- $this->dispatch('activityMonitor', $activity->id);
- $this->dispatch('databaserestore');
- $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
- } catch (\Throwable $e) {
- $this->importRunning = false;
- handleError($e, $this);
-
- return true;
- }
-
- return true;
- }
-
- public function buildRestoreCommand(string $tmpPath): string
- {
- $morphClass = $this->resource->getMorphClass();
-
- // Handle ServiceDatabase by checking the database type
- if ($morphClass === \App\Models\ServiceDatabase::class) {
- $dbType = $this->resource->databaseType();
- if (str_contains($dbType, 'mysql')) {
- $morphClass = 'mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $morphClass = 'mariadb';
- } elseif (str_contains($dbType, 'postgres')) {
- $morphClass = 'postgresql';
- } elseif (str_contains($dbType, 'mongo')) {
- $morphClass = 'mongodb';
- }
- }
-
- switch ($morphClass) {
- case \App\Models\StandaloneMariadb::class:
- case 'mariadb':
- $restoreCommand = $this->mariadbRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMysql::class:
- case 'mysql':
- $restoreCommand = $this->mysqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandalonePostgresql::class:
- case 'postgresql':
- $restoreCommand = $this->postgresqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
- } else {
- $restoreCommand .= " {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMongodb::class:
- case 'mongodb':
- $restoreCommand = $this->mongodbRestoreCommand;
- if ($this->dumpAll === false) {
- $restoreCommand .= "{$tmpPath}";
- }
- break;
- default:
- $restoreCommand = '';
- }
-
- return $restoreCommand;
+ return false;
}
}
diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php
new file mode 100644
index 000000000..ccc7b347d
--- /dev/null
+++ b/app/Livewire/Project/Database/ImportForm.php
@@ -0,0 +1,825 @@
+', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
+ }
+
+ /**
+ * Validate that a string is safe for use as a file path on the server.
+ */
+ private function validateServerPath(string $path): bool
+ {
+ // Must be an absolute path
+ if (! str_starts_with($path, '/')) {
+ return false;
+ }
+
+ // Must not contain dangerous shell metacharacters or command injection patterns
+ $dangerousPatterns = [
+ '..', // Directory traversal
+ '$(', // Command substitution
+ '`', // Backtick command substitution
+ '|', // Pipe
+ ';', // Command separator
+ '&', // Background/AND
+ '>', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
+ }
+
+ public bool $unsupported = false;
+
+ // Store IDs instead of models for proper Livewire serialization
+ #[Locked]
+ public ?int $resourceId = null;
+
+ #[Locked]
+ public ?string $resourceType = null;
+
+ #[Locked]
+ public ?int $serverId = null;
+
+ // View-friendly properties to avoid computed property access in Blade
+ #[Locked]
+ public string $resourceUuid = '';
+
+ public string $resourceStatus = '';
+
+ #[Locked]
+ public string $resourceDbType = '';
+
+ public array $parameters = [];
+
+ public array $containers = [];
+
+ public bool $scpInProgress = false;
+
+ public bool $importRunning = false;
+
+ public ?string $filename = null;
+
+ public ?string $filesize = null;
+
+ public bool $isUploading = false;
+
+ public int $progress = 0;
+
+ public bool $error = false;
+
+ #[Locked]
+ public string $container;
+
+ public array $importCommands = [];
+
+ public bool $dumpAll = false;
+
+ public string $restoreCommandText = '';
+
+ public string $customLocation = '';
+
+ public ?int $activityId = null;
+
+ public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+
+ public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
+
+ public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
+
+ public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
+
+ // S3 Restore properties
+ public array $availableS3Storages = [];
+
+ public ?int $s3StorageId = null;
+
+ public string $s3Path = '';
+
+ public ?int $s3FileSize = null;
+
+ #[Computed]
+ public function resource()
+ {
+ if ($this->resourceId === null || $this->resourceType === null) {
+ return null;
+ }
+
+ return $this->resourceType::find($this->resourceId);
+ }
+
+ #[Computed]
+ public function server()
+ {
+ if ($this->serverId === null) {
+ return null;
+ }
+
+ return Server::ownedByCurrentTeam()->find($this->serverId);
+ }
+
+ protected $listeners = [
+ 'slideOverClosed' => 'resetActivityId',
+ ];
+
+ public function resetActivityId()
+ {
+ $this->activityId = null;
+ }
+
+ public function mount()
+ {
+ $this->parameters = get_route_parameters();
+ $this->getContainers();
+ $this->loadAvailableS3Storages();
+ }
+
+ public function updatedDumpAll($value)
+ {
+ $morphClass = $this->resource->getMorphClass();
+
+ // Handle ServiceDatabase by checking the database type
+ if ($morphClass === ServiceDatabase::class) {
+ $dbType = $this->resource->databaseType();
+ if (str_contains($dbType, 'mysql')) {
+ $morphClass = 'mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $morphClass = 'mariadb';
+ } elseif (str_contains($dbType, 'postgres')) {
+ $morphClass = 'postgresql';
+ }
+ }
+
+ switch ($morphClass) {
+ case StandaloneMariadb::class:
+ case 'mariadb':
+ if ($value === true) {
+ $this->mariadbRestoreCommand = <<<'EOD'
+for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
+ mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
+done && \
+mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
+mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
+(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
+EOD;
+ $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
+ } else {
+ $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
+ }
+ break;
+ case StandaloneMysql::class:
+ case 'mysql':
+ if ($value === true) {
+ $this->mysqlRestoreCommand = <<<'EOD'
+for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
+ mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
+done && \
+mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
+mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
+(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
+EOD;
+ $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
+ } else {
+ $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
+ }
+ break;
+ case StandalonePostgresql::class:
+ case 'postgresql':
+ if ($value === true) {
+ $this->postgresqlRestoreCommand = <<<'EOD'
+psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
+psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
+createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
+EOD;
+ $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ } else {
+ $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ }
+ break;
+ }
+
+ }
+
+ public function getContainers()
+ {
+ $this->containers = [];
+ $teamId = data_get(auth()->user()->currentTeam(), 'id');
+
+ // Try to find resource by route parameter
+ $databaseUuid = data_get($this->parameters, 'database_uuid');
+ $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
+
+ $resource = null;
+ if ($databaseUuid) {
+ // Standalone database route
+ $resource = getResourceByUuid($databaseUuid, $teamId);
+ if (is_null($resource)) {
+ abort(404);
+ }
+ } elseif ($stackServiceUuid) {
+ // ServiceDatabase route - look up the service database
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', data_get($this->parameters, 'project_uuid'))
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', data_get($this->parameters, 'environment_uuid'))
+ ->firstOrFail();
+ $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
+ $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
+ if (is_null($resource)) {
+ abort(404);
+ }
+ } else {
+ abort(404);
+ }
+
+ $this->authorize('view', $resource);
+
+ // Store IDs for Livewire serialization
+ $this->resourceId = $resource->id;
+ $this->resourceType = get_class($resource);
+
+ // Store view-friendly properties
+ $this->resourceStatus = $resource->status ?? '';
+
+ // Handle ServiceDatabase server access differently
+ if ($resource->getMorphClass() === ServiceDatabase::class) {
+ $server = $resource->service?->server;
+ if (! $server) {
+ abort(404, 'Server not found for this service database.');
+ }
+ $this->serverId = $server->id;
+ $this->container = $resource->name.'-'.$resource->service->uuid;
+ $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
+
+ // Determine database type for ServiceDatabase
+ $dbType = $resource->databaseType();
+ if (str_contains($dbType, 'postgres')) {
+ $this->resourceDbType = 'standalone-postgresql';
+ } elseif (str_contains($dbType, 'mysql')) {
+ $this->resourceDbType = 'standalone-mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $this->resourceDbType = 'standalone-mariadb';
+ } elseif (str_contains($dbType, 'mongo')) {
+ $this->resourceDbType = 'standalone-mongodb';
+ } else {
+ $this->resourceDbType = $dbType;
+ }
+ } else {
+ $server = $resource->destination?->server;
+ if (! $server) {
+ abort(404, 'Server not found for this database.');
+ }
+ $this->serverId = $server->id;
+ $this->container = $resource->uuid;
+ $this->resourceUuid = $resource->uuid;
+ $this->resourceDbType = $resource->type();
+ }
+
+ if (str($resource->status)->startsWith('running')) {
+ $this->containers[] = $this->container;
+ }
+
+ if (
+ $resource->getMorphClass() === StandaloneRedis::class ||
+ $resource->getMorphClass() === StandaloneKeydb::class ||
+ $resource->getMorphClass() === StandaloneDragonfly::class ||
+ $resource->getMorphClass() === StandaloneClickhouse::class
+ ) {
+ $this->unsupported = true;
+ }
+
+ // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
+ if ($resource->getMorphClass() === ServiceDatabase::class) {
+ $dbType = $resource->databaseType();
+ if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
+ str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
+ $this->unsupported = true;
+ }
+ }
+ }
+
+ public function checkFile()
+ {
+ if (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return;
+ }
+
+ try {
+ $escapedPath = escapeshellarg($this->customLocation);
+ $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
+ if (blank($result)) {
+ $this->dispatch('error', 'The file does not exist or has been deleted.');
+
+ return;
+ }
+ $this->filename = $this->customLocation;
+ $this->dispatch('success', 'The file exists.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+ }
+
+ public function runImport(string $password = ''): bool|string
+ {
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ $this->authorize('update', $this->resource);
+
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
+ if ($this->filename === '') {
+ $this->dispatch('error', 'Please select a file to import.');
+
+ return true;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return true;
+ }
+
+ try {
+ $this->importRunning = true;
+ $this->importCommands = [];
+ $backupFileName = "upload/{$this->resourceUuid}/restore";
+
+ // Check if an uploaded file exists first (takes priority over custom location)
+ if (Storage::exists($backupFileName)) {
+ $path = Storage::path($backupFileName);
+ $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
+ instant_scp($path, $tmpPath, $this->server);
+ Storage::delete($backupFileName);
+ $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
+ } elseif (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
+
+ return true;
+ }
+ $tmpPath = '/tmp/restore_'.$this->resourceUuid;
+ $escapedCustomLocation = escapeshellarg($this->customLocation);
+ $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
+ } else {
+ $this->dispatch('error', 'The file does not exist or has been deleted.');
+
+ return true;
+ }
+
+ // Copy the restore command to a script file
+ $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
+
+ $restoreCommand = $this->buildRestoreCommand($tmpPath);
+
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
+ $this->importCommands[] = "chmod +x {$scriptPath}";
+ $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
+
+ $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
+ $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
+
+ if (! empty($this->importCommands)) {
+ $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
+ 'scriptPath' => $scriptPath,
+ 'tmpPath' => $tmpPath,
+ 'container' => $this->container,
+ 'serverId' => $this->server->id,
+ ]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ }
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+
+ return true;
+ } finally {
+ $this->filename = null;
+ $this->importCommands = [];
+ }
+
+ return true;
+ }
+
+ public function loadAvailableS3Storages()
+ {
+ try {
+ $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
+ ->where('is_usable', true)
+ ->get()
+ ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
+ ->toArray();
+ } catch (\Throwable $e) {
+ $this->availableS3Storages = [];
+ }
+ }
+
+ public function updatedS3Path($value)
+ {
+ // Reset validation state when path changes
+ $this->s3FileSize = null;
+
+ // Ensure path starts with a slash
+ if ($value !== null && $value !== '') {
+ $this->s3Path = str($value)->trim()->start('/')->value();
+ }
+ }
+
+ public function updatedS3StorageId()
+ {
+ // Reset validation state when storage changes
+ $this->s3FileSize = null;
+ }
+
+ public function checkS3File()
+ {
+ if (! $this->s3StorageId) {
+ $this->dispatch('error', 'Please select an S3 storage.');
+
+ return;
+ }
+
+ if (blank($this->s3Path)) {
+ $this->dispatch('error', 'Please provide an S3 path.');
+
+ return;
+ }
+
+ // Clean the path (remove leading slash if present)
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path early to prevent command injection in subsequent operations
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ try {
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ // Validate bucket name early
+ if (! $this->validateBucketName($s3Storage->bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return;
+ }
+
+ // Test connection
+ $s3Storage->testConnection();
+
+ // Build S3 disk configuration
+ $disk = Storage::build([
+ 'driver' => 's3',
+ 'region' => $s3Storage->region,
+ 'key' => $s3Storage->key,
+ 'secret' => $s3Storage->secret,
+ 'bucket' => $s3Storage->bucket,
+ 'endpoint' => $s3Storage->endpoint,
+ 'use_path_style_endpoint' => true,
+ ]);
+
+ // Check if file exists
+ if (! $disk->exists($cleanPath)) {
+ $this->dispatch('error', 'File not found in S3. Please check the path.');
+
+ return;
+ }
+
+ // Get file size
+ $this->s3FileSize = $disk->size($cleanPath);
+
+ $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
+ } catch (\Throwable $e) {
+ $this->s3FileSize = null;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function restoreFromS3(string $password = ''): bool|string
+ {
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ $this->authorize('update', $this->resource);
+
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
+ if (! $this->s3StorageId || blank($this->s3Path)) {
+ $this->dispatch('error', 'Please select S3 storage and provide a path first.');
+
+ return true;
+ }
+
+ if (is_null($this->s3FileSize)) {
+ $this->dispatch('error', 'Please check the file first by clicking "Check File".');
+
+ return true;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return true;
+ }
+
+ try {
+ $this->importRunning = true;
+
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ $key = $s3Storage->key;
+ $secret = $s3Storage->secret;
+ $bucket = $s3Storage->bucket;
+ $endpoint = $s3Storage->endpoint;
+
+ // Validate bucket name to prevent command injection
+ if (! $this->validateBucketName($bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return true;
+ }
+
+ // Clean the S3 path
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path to prevent command injection
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return true;
+ }
+
+ // Get helper image
+ $helperImage = config('constants.coolify.helper_image');
+ $latestVersion = getHelperVersion();
+ $fullImageName = "{$helperImage}:{$latestVersion}";
+
+ // Get the database destination network
+ if ($this->resource->getMorphClass() === ServiceDatabase::class) {
+ $destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
+ } else {
+ $destinationNetwork = $this->resource->destination->network ?? 'coolify';
+ }
+
+ // Generate unique names for this operation
+ $containerName = "s3-restore-{$this->resourceUuid}";
+ $helperTmpPath = '/tmp/'.basename($cleanPath);
+ $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
+ $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
+ $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
+
+ $escapedServerTmpPath = escapeshellarg($serverTmpPath);
+ $escapedContainerTmpPath = escapeshellarg($containerTmpPath);
+ $escapedScriptPath = escapeshellarg($scriptPath);
+ $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
+ $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
+ $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
+ $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
+
+ // Prepare all commands in sequence
+ $commands = [];
+
+ // 1. Clean up any existing helper container and temp files from previous runs
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
+ $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
+
+ // 2. Start helper container on the database network
+ $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
+
+ // 3. Configure S3 access in helper container
+ $escapedEndpoint = escapeshellarg($endpoint);
+ $escapedKey = escapeshellarg($key);
+ $escapedSecret = escapeshellarg($secret);
+ $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
+
+ // 4. Check file exists in S3 (bucket and path already validated above)
+ $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
+ $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
+
+ // 5. Download from S3 to helper container (progress shown by default)
+ $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
+ $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
+
+ // 6. Copy from helper to server, then immediately to database container
+ $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
+ $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
+
+ // 7. Cleanup helper container and server temp file immediately (no longer needed)
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
+
+ // 8. Build and execute restore command inside database container
+ $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
+
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
+ $commands[] = "chmod +x {$escapedScriptPath}";
+ $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
+
+ // 9. Execute restore and cleanup temp files immediately after completion
+ $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
+ $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
+
+ // Execute all commands with cleanup event (as safety net for edge cases)
+ $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
+ 'containerName' => $containerName,
+ 'serverTmpPath' => $serverTmpPath,
+ 'scriptPath' => $scriptPath,
+ 'containerTmpPath' => $containerTmpPath,
+ 'container' => $this->container,
+ 'serverId' => $this->server->id,
+ ]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
+ } catch (\Throwable $e) {
+ $this->importRunning = false;
+ handleError($e, $this);
+
+ return true;
+ }
+
+ return true;
+ }
+
+ public function buildRestoreCommand(string $tmpPath): string
+ {
+ $escapedTmpPath = escapeshellarg($tmpPath);
+ $morphClass = $this->resource->getMorphClass();
+
+ // Handle ServiceDatabase by checking the database type
+ if ($morphClass === ServiceDatabase::class) {
+ $dbType = $this->resource->databaseType();
+ if (str_contains($dbType, 'mysql')) {
+ $morphClass = 'mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $morphClass = 'mariadb';
+ } elseif (str_contains($dbType, 'postgres')) {
+ $morphClass = 'postgresql';
+ } elseif (str_contains($dbType, 'mongo')) {
+ $morphClass = 'mongodb';
+ }
+ }
+
+ switch ($morphClass) {
+ case StandaloneMariadb::class:
+ case 'mariadb':
+ $restoreCommand = $this->mariadbRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
+ } else {
+ $restoreCommand .= " < {$escapedTmpPath}";
+ }
+ break;
+ case StandaloneMysql::class:
+ case 'mysql':
+ $restoreCommand = $this->mysqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
+ } else {
+ $restoreCommand .= " < {$escapedTmpPath}";
+ }
+ break;
+ case StandalonePostgresql::class:
+ case 'postgresql':
+ $restoreCommand = $this->postgresqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
+ } else {
+ $restoreCommand .= " {$escapedTmpPath}";
+ }
+ break;
+ case StandaloneMongodb::class:
+ case 'mongodb':
+ $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
+ break;
+ default:
+ $restoreCommand = '';
+ }
+
+ return $restoreCommand;
+ }
+}
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index adb4ccb5f..974803e8d 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -36,30 +34,27 @@ 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;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public ?Carbon $certificateValidUntil = null;
-
- public bool $enable_ssl = false;
-
- public function getListeners()
+ public function getListeners(): array
{
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -74,12 +69,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -87,36 +76,35 @@ class General extends Component
protected function rules(): array
{
- $baseRules = [
+ return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
- 'keydbPassword' => 'required|string',
+ 'keydbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
+ ),
'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',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
- 'enable_ssl' => 'boolean',
];
-
- return $baseRules;
}
protected function messages(): array
{
return array_merge(
ValidationPatterns::combinedMessages(),
+ ValidationPatterns::portMappingMessages(),
[
- 'keydbPassword.required' => 'The KeyDB Password field is required.',
- 'keydbPassword.string' => 'The KeyDB Password must be a string.',
+ ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'),
'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,15 +122,11 @@ 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;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -155,9 +139,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->enable_ssl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -206,6 +187,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -214,9 +196,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -224,11 +210,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -240,51 +230,9 @@ class General extends Component
}
}
- public function instantSaveSSL()
+ public function refresh(): void
{
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()
- ->where('is_ca_certificate', true)
- ->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->commonName,
- subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
+ $this->database->refresh();
+ $this->syncData();
}
}
diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php
new file mode 100644
index 000000000..1e87461cd
--- /dev/null
+++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php
@@ -0,0 +1,26 @@
+ '$refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mariadbRootPassword' => 'required',
- 'mariadbUser' => 'required',
- 'mariadbPassword' => 'required',
- 'mariadbDatabase' => 'required',
+ 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password,
+ ),
+ 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbUser !== $this->database->mariadb_user,
+ ),
+ 'mariadbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password,
+ ),
+ 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database,
+ ),
'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',
- 'enableSsl' => 'boolean',
];
}
@@ -92,14 +79,17 @@ 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.',
- 'mariadbUser.required' => 'The MariaDB User field is required.',
- 'mariadbPassword.required' => 'The MariaDB Password field is required.',
- 'mariadbDatabase.required' => 'The MariaDB Database field is required.',
+ ...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'),
+ ...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'),
'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.',
]
@@ -120,7 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -134,12 +123,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -159,15 +142,11 @@ 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;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -183,9 +162,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -213,11 +189,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -254,6 +234,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -262,52 +243,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php
new file mode 100644
index 000000000..c6fda37b6
--- /dev/null
+++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php
@@ -0,0 +1,21 @@
+ '$refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
- 'mongoInitdbRootUsername' => 'required',
- 'mongoInitdbRootPassword' => 'required',
- 'mongoInitdbDatabase' => 'required',
+ 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username,
+ ),
+ 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password,
+ ),
+ 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database,
+ ),
'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',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@@ -92,16 +74,18 @@ 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.',
- 'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
- 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'),
+ ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'),
'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.',
]
);
}
@@ -119,8 +103,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -134,12 +116,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -158,16 +134,11 @@ 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;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -182,10 +153,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -213,6 +180,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;
}
@@ -221,6 +191,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -257,6 +228,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -265,57 +237,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php
new file mode 100644
index 000000000..a92a682c9
--- /dev/null
+++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php
@@ -0,0 +1,51 @@
+ ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
+ 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
+ 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
+ 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for MongoDB connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index 4f0f5eb19..6b88d735d 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -42,52 +39,39 @@ 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;
public ?string $customDockerRunOptions = null;
- public bool $enableSsl = false;
-
- public ?string $sslMode = null;
-
- public ?string $db_url = null;
-
- public ?string $db_url_public = null;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mysqlRootPassword' => 'required',
- 'mysqlUser' => 'required',
- 'mysqlPassword' => 'required',
- 'mysqlDatabase' => 'required',
+ 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password,
+ ),
+ 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlUser !== $this->database->mysql_user,
+ ),
+ 'mysqlPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlPassword !== $this->database->mysql_password,
+ ),
+ 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database,
+ ),
'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',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@@ -95,17 +79,19 @@ 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.',
- 'mysqlUser.required' => 'The MySQL User field is required.',
- 'mysqlPassword.required' => 'The MySQL Password field is required.',
- 'mysqlDatabase.required' => 'The MySQL Database field is required.',
+ ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'),
+ ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'),
'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.',
]
);
}
@@ -124,8 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -139,12 +123,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -164,16 +142,11 @@ 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;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -189,10 +162,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -220,11 +189,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -261,6 +234,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -269,57 +243,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php
new file mode 100644
index 000000000..5fbbc1583
--- /dev/null
+++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php
@@ -0,0 +1,51 @@
+ ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
+ 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
+ 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
+ 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for MySQL connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 4e044672b..4e89e8b62 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -46,60 +43,48 @@ 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;
public ?string $customDockerRunOptions = null;
- public bool $enableSsl = false;
-
- public ?string $sslMode = null;
-
public string $new_filename;
public string $new_content;
- public ?string $db_url = null;
-
- public ?string $db_url_public = null;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
- 'save_init_script',
- 'delete_init_script',
- ];
- }
+ protected $listeners = [
+ 'save_init_script',
+ 'delete_init_script',
+ ];
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'postgresUser' => 'required',
- 'postgresPassword' => 'required',
- 'postgresDb' => 'required',
+ 'postgresUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresUser !== $this->database->postgres_user,
+ ),
+ 'postgresPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->postgresPassword !== $this->database->postgres_password,
+ ),
+ 'postgresDb' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresDb !== $this->database->postgres_db,
+ ),
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'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',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@@ -107,16 +92,18 @@ 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.',
- 'postgresPassword.required' => 'The Postgres Password field is required.',
- 'postgresDb.required' => 'The Postgres Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'),
+ ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'),
'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.',
]
);
}
@@ -137,8 +124,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -152,12 +137,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -179,16 +158,11 @@ 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;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -206,10 +180,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -232,57 +202,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function instantSave()
{
try {
@@ -308,6 +227,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -336,9 +256,14 @@ class General extends Component
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($oldScript['filename'], 'init script filename');
- $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
+ // New filename is user-supplied — must be safe before accepting the rename.
+ validateFilenameSafe($script['filename'], 'init script filename');
+
+ // Old filename may be a legacy value written before this validation existed.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
+ // any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
+ $old_filename = basename($oldScript['filename']);
+ $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
@@ -382,9 +307,11 @@ class General extends Component
$configuration_dir = database_configuration_dir().'/'.$container_name;
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($script['filename'], 'init script filename');
- $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
+ // Allow deletion of legacy rows with unsafe filenames so operators can clean up.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the
+ // shell invocation safe regardless of the stored value.
+ $safe_filename = basename($script['filename']);
+ $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
@@ -421,8 +348,8 @@ class General extends Component
]);
try {
- // Validate filename to prevent command injection
- validateShellSafePath($this->new_filename, 'init script filename');
+ // Validate filename to prevent path traversal and command injection
+ validateFilenameSafe($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -456,11 +383,15 @@ 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;
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -471,4 +402,10 @@ class General extends Component
}
}
}
+
+ public function refresh(): void
+ {
+ $this->database->refresh();
+ $this->syncData();
+ }
}
diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php
new file mode 100644
index 000000000..cc27b61bb
--- /dev/null
+++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php
@@ -0,0 +1,52 @@
+ ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
+ 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
+ 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
+ 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
+ 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for PostgreSQL connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index ebe2f3ba0..aff7b7afa 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -34,9 +31,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;
@@ -48,23 +45,9 @@ class General extends Component
public string $redisVersion;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
- public bool $enableSsl = false;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
- 'envsUpdated' => 'refresh',
- ];
- }
+ protected $listeners = [
+ 'envsUpdated' => 'refresh',
+ ];
protected function rules(): array
{
@@ -73,15 +56,18 @@ 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',
- 'redisUsername' => 'required',
- 'redisPassword' => 'required',
- 'enableSsl' => 'boolean',
+ 'redisUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->redisUsername !== $this->database->redis_username,
+ ),
+ 'redisPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->redisPassword !== $this->database->redis_password,
+ ),
];
}
@@ -89,14 +75,17 @@ 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.',
- 'redisPassword.required' => 'The Redis Password field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'),
+ ...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'),
]
);
}
@@ -113,7 +102,6 @@ class General extends Component
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -127,12 +115,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -148,15 +130,11 @@ 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;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -168,9 +146,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password;
@@ -201,6 +176,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', '>=')) {
@@ -215,6 +193,7 @@ class General extends Component
);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -247,6 +226,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -255,52 +235,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->commonName,
- subjectAlternativeNames: $existingCert->subjectAlternativeNames ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php
new file mode 100644
index 000000000..2e784e2c0
--- /dev/null
+++ b/app/Livewire/Project/Database/Redis/StatusInfo.php
@@ -0,0 +1,21 @@
+environmentName = Environment::findOrFail($this->environment_id)->name;
- $this->parameters = get_route_parameters();
- } catch (\Exception $e) {
- return handleError($e, $this);
- }
+ $this->parameters = get_route_parameters();
+ $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
}
public function delete()
@@ -33,7 +31,7 @@ class DeleteEnvironment extends Component
$this->validate([
'environment_id' => 'required|int',
]);
- $environment = Environment::findOrFail($this->environment_id);
+ $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {
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..2cf0659bf 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -31,7 +29,6 @@ class DockerCompose extends Component
public function submit()
{
- $server_id = $this->query['server_id'];
try {
$this->validate([
'dockerComposeRaw' => 'required',
@@ -41,23 +38,20 @@ 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();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 8aff83153..737806cb8 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -4,9 +4,8 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Services\DockerImageParser;
+use App\Support\ValidationPatterns;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -83,8 +82,8 @@ class DockerImage extends Component
public function submit()
{
$this->validate([
- 'imageName' => ['required', 'string'],
- 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
+ 'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
+ 'imageTag' => ValidationPatterns::dockerImageTagRules(),
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
@@ -111,18 +110,15 @@ class DockerImage extends Component
$parser = new DockerImageParser;
$parser->parse($dockerImage);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$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..1c9c8e896 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -5,11 +5,11 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
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\Attributes\Locked;
use Livewire\Component;
class GithubPrivateRepository extends Component
@@ -30,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id;
+ #[Locked]
public int $selected_github_app_id;
public string $selected_repository_owner;
@@ -38,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main';
- public string $token;
-
public $repositories;
public int $total_repositories_count = 0;
@@ -72,7 +71,10 @@ class GithubPrivateRepository extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->repositories = $this->branches = collect();
- $this->github_apps = GithubApp::private();
+ $this->github_apps = GithubApp::ownedByCurrentTeam()
+ ->where('is_public', false)
+ ->whereNotNull('app_id')
+ ->get();
}
public function updatedSelectedRepositoryId(): void
@@ -82,9 +84,11 @@ class GithubPrivateRepository extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
@@ -95,20 +99,25 @@ class GithubPrivateRepository extends Component
}
}
- public function loadRepositories($github_app_id)
+ public function loadRepositories(int $github_app_id): void
{
$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();
- $this->token = generateGithubInstallationToken($this->github_app);
- $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
+ $this->github_app = GithubApp::ownedByCurrentTeam()
+ ->where('is_public', false)
+ ->whereNotNull('app_id')
+ ->findOrFail($github_app_id);
+ $token = generateGithubInstallationToken($this->github_app);
+ $repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++;
- $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
+ $repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
}
@@ -139,7 +148,9 @@ class GithubPrivateRepository extends Component
protected function loadBranchByPage()
{
- $response = Http::GitHub($this->github_app->api_url, $this->token)
+ $token = generateGithubInstallationToken($this->github_app);
+
+ $response = Http::GitHub($this->github_app->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
@@ -168,25 +179,22 @@ 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()) {
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$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..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
-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 +65,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(),
];
}
@@ -95,9 +94,11 @@ class GithubPrivateRepositoryDeployKey extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
@@ -129,13 +130,10 @@ class GithubPrivateRepositoryDeployKey extends Component
{
$this->validate();
try {
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
@@ -144,8 +142,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..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -7,10 +7,9 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
-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;
@@ -33,8 +32,6 @@ class PublicGitRepository extends Component
public bool $isStatic = false;
- public bool $checkCoolifyConfig = true;
-
public ?string $publish_directory = null;
// In case of docker compose
@@ -72,7 +69,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],
];
}
@@ -99,9 +96,11 @@ class PublicGitRepository extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->isStatic) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->isStatic = false;
@@ -207,13 +206,8 @@ class PublicGitRepository extends Component
if ($this->repository_url_parsed->getSegment(3) === 'tree') {
$path = str($this->repository_url_parsed->getPath())->trim('/');
- $this->git_branch = str($path)->after('tree/')->before('/')->value();
- $this->base_directory = str($path)->after($this->git_branch)->after('/')->value();
- if (filled($this->base_directory)) {
- $this->base_directory = '/'.$this->base_directory;
- } else {
- $this->base_directory = '/';
- }
+ $this->git_branch = str($path)->after('tree/')->value();
+ $this->base_directory = '/';
} else {
$this->git_branch = 'main';
}
@@ -233,10 +227,33 @@ class PublicGitRepository extends Component
return;
}
- if ($this->git_source->getMorphClass() === \App\Models\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;
+ if ($this->git_source->getMorphClass() === GithubApp::class) {
+ $originalBranch = $this->git_branch;
+ $branchToTry = $originalBranch;
+
+ while (true) {
+ try {
+ $encodedBranch = urlencode($branchToTry);
+ ['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/{$encodedBranch}");
+ $this->rate_limit_reset = Carbon::parse((int) $this->rate_limit_reset)->format('Y-M-d H:i:s');
+ $this->git_branch = $branchToTry;
+
+ $remaining = str($originalBranch)->after($branchToTry)->trim('/')->value();
+ $this->base_directory = filled($remaining) ? '/'.$remaining : '/';
+
+ $this->branchFound = true;
+
+ return;
+ } catch (\Throwable $e) {
+ if (str_contains($branchToTry, '/')) {
+ $branchToTry = str($branchToTry)->beforeLast('/')->value();
+
+ continue;
+ }
+
+ throw $e;
+ }
+ }
}
}
@@ -265,21 +282,18 @@ class PublicGitRepository extends Component
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
- $destination_uuid = $this->query['destination'];
+ $destination_uuid = $this->query['destination'] ?? null;
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$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;
@@ -352,12 +366,6 @@ class PublicGitRepository extends Component
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;
$application->save();
- if ($this->checkCoolifyConfig) {
- // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
- // if ($config) {
- // $application->setConfig($config);
- // }
- }
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
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..f07948dba 100644
--- a/app/Livewire/Project/New/SimpleDockerfile.php
+++ b/app/Livewire/Project/New/SimpleDockerfile.php
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -35,18 +33,15 @@ CMD ["nginx", "-g", "daemon off;"]
$this->validate([
'dockerfile' => 'required',
]);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$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/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index 966c66a14..4619ddf37 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Resource;
use App\Models\EnvironmentVariable;
use App\Models\Service;
-use App\Models\StandaloneDocker;
use Livewire\Component;
class Create extends Component
@@ -18,7 +17,6 @@ class Create extends Component
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
- $server_id = request()->query('server_id');
$database_image = request()->query('database_image');
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
@@ -30,7 +28,11 @@ class Create extends Component
if (! $environment) {
return redirect()->route('dashboard');
}
- if (isset($type) && isset($destination_uuid) && isset($server_id)) {
+ if (isset($type) && isset($destination_uuid)) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
+ return redirect()->route('dashboard');
+ }
$services = get_service_templates();
if (in_array($type, DATABASE_TYPES)) {
@@ -44,23 +46,23 @@ class Create extends Component
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
- destinationUuid: $destination_uuid,
+ destination: $destination,
databaseImage: $database_image
);
} elseif ($type->value() === 'redis') {
- $database = create_standalone_redis($environment->id, $destination_uuid);
+ $database = create_standalone_redis($environment->id, $destination);
} elseif ($type->value() === 'mongodb') {
- $database = create_standalone_mongodb($environment->id, $destination_uuid);
+ $database = create_standalone_mongodb($environment->id, $destination);
} elseif ($type->value() === 'mysql') {
- $database = create_standalone_mysql($environment->id, $destination_uuid);
+ $database = create_standalone_mysql($environment->id, $destination);
} elseif ($type->value() === 'mariadb') {
- $database = create_standalone_mariadb($environment->id, $destination_uuid);
+ $database = create_standalone_mariadb($environment->id, $destination);
} elseif ($type->value() === 'keydb') {
- $database = create_standalone_keydb($environment->id, $destination_uuid);
+ $database = create_standalone_keydb($environment->id, $destination);
} elseif ($type->value() === 'dragonfly') {
- $database = create_standalone_dragonfly($environment->id, $destination_uuid);
+ $database = create_standalone_dragonfly($environment->id, $destination);
} elseif ($type->value() === 'clickhouse') {
- $database = create_standalone_clickhouse($environment->id, $destination_uuid);
+ $database = create_standalone_clickhouse($environment->id, $destination);
}
return redirect()->route('project.database.configuration', [
@@ -69,7 +71,7 @@ class Create extends Component
'database_uuid' => $database->uuid,
]);
}
- if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
+ if ($type->startsWith('one-click-service-')) {
$oneClickServiceName = $type->after('one-click-service-')->value();
$oneClickService = data_get($services, "$oneClickServiceName.compose");
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
@@ -79,12 +81,11 @@ class Create extends Component
});
}
if ($oneClickService) {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first();
$service_payload = [
'docker_compose_raw' => base64_decode($oneClickService),
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php
index 2d69ceb12..caa19042b 100644
--- a/app/Livewire/Project/Service/Configuration.php
+++ b/app/Livewire/Project/Service/Configuration.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Configuration extends Component
@@ -27,16 +26,10 @@ class Configuration extends Component
public array $parameters;
- public function getListeners()
- {
- $teamId = Auth::user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
- 'refreshServices' => 'refreshServices',
- 'refresh' => 'refreshServices',
- ];
- }
+ protected $listeners = [
+ 'refreshServices' => 'refreshServices',
+ 'refresh' => 'refreshServices',
+ ];
public function render()
{
@@ -51,7 +44,7 @@ class Configuration extends Component
$this->query = request()->query();
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -105,18 +98,4 @@ class Configuration extends Component
return handleError($e, $this);
}
}
-
- public function serviceChecked()
- {
- try {
- $this->service->applications->each(function ($application) {
- $application->refresh();
- });
- $this->service->databases->each(function ($database) {
- $database->refresh();
- });
- } catch (\Exception $e) {
- return handleError($e, $this);
- }
- }
}
diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php
index 826a6c1ff..883441ecb 100644
--- a/app/Livewire/Project/Service/DatabaseBackups.php
+++ b/app/Livewire/Project/Service/DatabaseBackups.php
@@ -28,10 +28,16 @@ class DatabaseBackups extends Component
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
- $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
- if (! $this->service) {
- return redirect()->route('dashboard');
- }
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', $this->parameters['project_uuid'])
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', $this->parameters['environment_uuid'])
+ ->firstOrFail();
+ $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 844e37854..2f1a229b4 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -63,13 +63,16 @@ class FileStorage extends Component
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
+ if ($this->fileStorage->is_too_large) {
+ return;
+ }
$this->validate();
// Sync to model
@@ -172,6 +175,12 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
+
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@@ -197,6 +206,11 @@ class FileStorage extends Component
public function instantSave(): void
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php
index c8a08d8f9..60273ab23 100644
--- a/app/Livewire/Project/Service/Heading.php
+++ b/app/Livewire/Project/Service/Heading.php
@@ -7,12 +7,15 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
+ use AuthorizesRequests;
+
public Service $service;
public array $parameters;
@@ -27,6 +30,8 @@ class Heading extends Component
public function mount()
{
+ $this->authorizeService('view');
+
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@@ -47,6 +52,8 @@ class Heading extends Component
public function checkStatus()
{
+ $this->authorizeService('view');
+
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@@ -61,6 +68,8 @@ class Heading extends Component
public function serviceChecked()
{
+ $this->authorizeService('view');
+
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@@ -82,6 +91,8 @@ class Heading extends Component
public function checkDeployments()
{
+ $this->authorizeService('view');
+
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
@@ -99,12 +110,16 @@ class Heading extends Component
public function start()
{
+ $this->authorizeService('deploy');
+
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
{
+ $this->authorizeService('deploy');
+
try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
@@ -124,6 +139,8 @@ class Heading extends Component
public function stop()
{
+ $this->authorizeService('stop');
+
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@@ -133,6 +150,8 @@ class Heading extends Component
public function restart()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -145,6 +164,8 @@ class Heading extends Component
public function pullAndRestartEvent()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -155,6 +176,15 @@ class Heading extends Component
$this->dispatch('activityMonitor', $activity->id);
}
+ private function authorizeService(string $ability): void
+ {
+ $this->service = Service::ownedByCurrentTeam()
+ ->whereKey($this->service->getKey())
+ ->firstOrFail();
+
+ $this->authorize($ability, $this->service);
+ }
+
public function render()
{
return view('livewire.project.service.heading', [
diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php
index c77a3a516..12c0edbca 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',
@@ -108,10 +108,16 @@ class Index extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
- $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
- if (! $this->service) {
- return redirect()->route('dashboard');
- }
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', $this->parameters['project_uuid'])
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', $this->parameters['environment_uuid'])
+ ->firstOrFail();
+ $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
@@ -160,8 +166,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/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php
new file mode 100644
index 000000000..fd27f60c3
--- /dev/null
+++ b/app/Livewire/Project/Service/ResourceCard.php
@@ -0,0 +1,66 @@
+currentTeam();
+ if (! $team) {
+ return [];
+ }
+
+ return [
+ "echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
+ ];
+ }
+
+ public function refreshResource(): void
+ {
+ $this->resource->refresh();
+ }
+
+ public function restart(): void
+ {
+ try {
+ $this->authorize('update', $this->service);
+ $this->resource->restart();
+ $message = $this->resource instanceof ServiceApplication
+ ? 'Service application restarted successfully.'
+ : 'Service database restarted successfully.';
+ $this->dispatch('success', $message);
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.service.resource-card', [
+ 'isApplication' => $this->resource instanceof ServiceApplication,
+ 'isDatabase' => $this->resource instanceof ServiceDatabase,
+ ]);
+ }
+}
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 12d8bcbc3..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -2,7 +2,10 @@
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;
@@ -49,7 +52,7 @@ class Storage extends Component
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ if ($this->resource->getMorphClass() === Application::class) {
if ($this->resource->destination->server->isSwarm()) {
$this->isSwarm = true;
}
@@ -66,7 +69,11 @@ class Storage extends Component
public function refreshStorages()
{
- $this->fileStorage = $this->resource->fileStorages()->get();
+ $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
+ if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
+ $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+ }
+ });
$this->resource->load('persistentStorages.resource');
}
@@ -101,10 +108,14 @@ 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',
- ]);
+ 'host_path' => $this->isSwarm
+ ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
+ : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ ], array_merge(ValidationPatterns::volumeNameMessages(), [
+ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
+ ]));
$name = $this->resource->uuid.'-'.$this->name;
@@ -138,7 +149,10 @@ class Storage extends Component
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
- if ($this->resource->getMorphClass() === \App\Models\Application::class) {
+ // Validate path to prevent command injection
+ validateShellSafePath($this->file_storage_path, 'file storage path');
+
+ if ($this->resource->getMorphClass() === Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
@@ -146,7 +160,7 @@ class Storage extends Component
throw new \Exception('No valid resource type for file mount storage type!');
}
- \App\Models\LocalFileVolume::create([
+ LocalFileVolume::create([
'fs_path' => $fs_path,
'mount_path' => $this->file_storage_path,
'content' => $this->file_storage_content,
@@ -183,7 +197,7 @@ class Storage extends Component
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
- \App\Models\LocalFileVolume::create([
+ LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
'is_directory' => true,
diff --git a/app/Livewire/Project/Shared/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php
index ce9ce7780..43bf3140b 100644
--- a/app/Livewire/Project/Shared/ConfigurationChecker.php
+++ b/app/Livewire/Project/Shared/ConfigurationChecker.php
@@ -12,15 +12,18 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use Illuminate\Contracts\View\View;
use Livewire\Component;
class ConfigurationChecker extends Component
{
public bool $isConfigurationChanged = false;
+ public array $configurationDiff = [];
+
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
- public function getListeners()
+ public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
@@ -30,18 +33,71 @@ class ConfigurationChecker extends Component
];
}
- public function mount()
+ public function mount(): void
{
$this->configurationChanged();
}
- public function render()
+ public function render(): View
{
return view('livewire.project.shared.configuration-checker');
}
- public function configurationChanged()
+ public function refreshConfigurationChanges(): void
{
+ $this->configurationChanged();
+ }
+
+ /**
+ * Members must never see environment variable values, so redact every
+ * environment-section change before it is serialized to the browser.
+ *
+ * @param array> $changes
+ * @return array>
+ */
+ private function redactEnvironmentChanges(array $changes, bool $redact): array
+ {
+ if (! $redact) {
+ return $changes;
+ }
+
+ return collect($changes)
+ ->map(function (array $change): array {
+ if (data_get($change, 'section') !== 'environment') {
+ return $change;
+ }
+
+ $change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
+ $change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
+ $change['old_full_value'] = null;
+ $change['new_full_value'] = null;
+ $change['expandable'] = false;
+ $change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
+
+ return $change;
+ })
+ ->all();
+ }
+
+ public function configurationChanged(): void
+ {
+ $this->resource->refresh();
+
+ if ($this->resource instanceof Application) {
+ $diff = $this->resource->pendingDeploymentConfigurationDiff();
+ // Fail closed: only owners/admins may see unlocked env values.
+ $redactEnvironment = ! (bool) auth()->user()?->isAdmin();
+
+ $array = $diff->toArray();
+ $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
+
+ $this->isConfigurationChanged = $diff->isChanged();
+ $this->configurationDiff = $array;
+
+ return;
+ }
+
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
+ $this->configurationDiff = [];
}
}
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 363471760..715ce82a7 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -110,15 +110,27 @@ class Destination extends Component
public function promote(int $network_id, int $server_id)
{
- $main_destination = $this->resource->destination;
- $this->resource->update([
- 'destination_id' => $network_id,
- 'destination_type' => StandaloneDocker::class,
- ]);
- $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
- $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
- $this->refreshServers();
- $this->resource->refresh();
+ try {
+ $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
+ $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
+ $this->authorize('update', $this->resource);
+
+ $this->resource->getConnection()->transaction(function () use ($network, $server) {
+ $main_destination = $this->resource->destination;
+ $this->resource->update([
+ 'destination_id' => $network->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+ $this->resource->additional_networks()
+ ->wherePivot('server_id', $server->id)
+ ->detach($network->id);
+ $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
+ });
+ $this->resource->refresh();
+ $this->refreshServers();
+ } catch (\Exception $e) {
+ return handleError($e, $this);
+ }
}
public function refreshServers()
@@ -130,8 +142,16 @@ class Destination extends Component
public function addServer(int $network_id, int $server_id)
{
- $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
- $this->dispatch('refresh');
+ try {
+ $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
+ $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
+ $this->authorize('update', $this->resource);
+
+ $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
+ $this->dispatch('refresh');
+ } catch (\Exception $e) {
+ return handleError($e, $this);
+ }
}
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
@@ -148,7 +168,9 @@ class Destination extends Component
}
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
StopApplicationOneServer::run($this->resource, $server);
- $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
+ $this->resource->additional_networks()
+ ->wherePivot('server_id', $server_id)
+ ->detach($network_id);
$this->loadData();
$this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index 73d5393b0..1dcb7c781 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
@@ -2,9 +2,14 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -37,15 +42,23 @@ class Add extends Component
protected $listeners = ['clearAddEnv' => 'clear'];
- protected $rules = [
- 'key' => 'required|string',
- 'value' => 'nullable',
- 'is_multiline' => 'required|boolean',
- 'is_literal' => 'required|boolean',
- 'is_runtime' => 'required|boolean',
- 'is_buildtime' => 'required|boolean',
- 'comment' => 'nullable|string|max:256',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
+ 'value' => 'nullable',
+ 'is_multiline' => 'required|boolean',
+ 'is_literal' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
+ 'comment' => 'nullable|string|max:256',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return ValidationPatterns::environmentVariableKeyMessages('key');
+ }
protected $validationAttributes = [
'key' => 'key',
@@ -71,6 +84,7 @@ class Add extends Component
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -84,7 +98,7 @@ class Add extends Component
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -115,22 +129,83 @@ class Add extends Component
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
+ // 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 = 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 (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 = 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 (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 = 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 (AuthorizationException $e) {
+ // User not authorized to view server variables
+ }
+ }
+ }
+ }
+ }
+
return $result;
}
public function submit()
{
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index f250a860b..53b55009e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\EnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -38,7 +40,7 @@ class All extends Component
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
- $resourceWithPreviews = [\App\Models\Application::class];
+ $resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@@ -194,7 +196,7 @@ class All extends Component
private function updateOrder()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@@ -206,7 +208,7 @@ class All extends Component
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@@ -221,7 +223,7 @@ class All extends Component
private function handleBulkSubmit()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -241,7 +243,7 @@ class All extends Component
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@@ -267,6 +269,7 @@ class All extends Component
private function handleSingleSubmit($data)
{
+ $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@@ -334,6 +337,23 @@ class All extends Component
return $variablesToDelete->count();
}
+ private function normalizeEnvironmentVariables(array $variables): array
+ {
+ $normalizedVariables = [];
+
+ foreach ($variables as $key => $data) {
+ $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
+
+ if (array_key_exists($normalizedKey, $normalizedVariables)) {
+ throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
+ }
+
+ $normalizedVariables[$normalizedKey] = $data;
+ }
+
+ return $normalizedVariables;
+ }
+
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index c567d96aa..26369852e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -64,23 +69,31 @@ class Show extends Component
'compose_loaded' => '$refresh',
];
- protected $rules = [
- 'key' => 'required|string',
- 'value' => 'nullable',
- 'comment' => 'nullable|string|max:256',
- 'is_multiline' => 'required|boolean',
- 'is_literal' => 'required|boolean',
- 'is_shown_once' => 'required|boolean',
- 'is_runtime' => 'required|boolean',
- 'is_buildtime' => 'required|boolean',
- 'real_value' => 'nullable',
- 'is_required' => 'required|boolean',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
+ 'value' => 'nullable',
+ 'comment' => 'nullable|string|max:256',
+ 'is_multiline' => 'required|boolean',
+ 'is_literal' => 'required|boolean',
+ 'is_shown_once' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
+ 'real_value' => 'nullable',
+ 'is_required' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return ValidationPatterns::environmentVariableKeyMessages('key');
+ }
public function mount()
{
$this->syncData();
- if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
+ if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
}
$this->parameters = get_route_parameters();
@@ -108,9 +121,11 @@ class Show extends Component
public function syncData(bool $toModel = false)
{
if ($toModel) {
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+
if ($this->isSharedVariable) {
$this->validate([
- 'key' => 'required|string',
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@@ -219,6 +234,7 @@ class Show extends Component
'team' => [],
'project' => [],
'environment' => [],
+ 'server' => [],
];
// Early return if no team
@@ -232,7 +248,7 @@ class Show extends Component
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -263,17 +279,77 @@ class Show extends Component
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
}
+ // 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 = 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 (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 = 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 (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 = 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 (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/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index 0d5d71b45..195e7fd92 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -34,7 +34,7 @@ class HealthChecks extends Component
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
- #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
+ #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@@ -62,7 +62,7 @@ class HealthChecks extends Component
'healthCheckEnabled' => 'boolean',
'healthCheckType' => 'string|in:http,cmd',
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
- 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
+ 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
diff --git a/app/Livewire/Project/Shared/ResourceDetails.php b/app/Livewire/Project/Shared/ResourceDetails.php
new file mode 100644
index 000000000..8a4117c39
--- /dev/null
+++ b/app/Livewire/Project/Shared/ResourceDetails.php
@@ -0,0 +1,91 @@
+authorize('view', $this->resource);
+
+ $environment = $this->resource->environment ?? null;
+ if ($environment) {
+ $this->environment_uuid = $environment->uuid;
+ $this->environment_name = $environment->name;
+ $project = $environment->project ?? null;
+ if ($project) {
+ $this->project_uuid = $project->uuid;
+ $this->project_name = $project->name;
+ }
+ }
+
+ $server = $this->resolveServer();
+ if ($server) {
+ $this->server_uuid = $server->uuid;
+ $this->server_name = $server->name;
+ }
+
+ if ($this->resource instanceof Service) {
+ $this->stack_applications = $this->resource->applications
+ ->map(fn ($app) => [
+ 'name' => $app->human_name ?: $app->name,
+ 'uuid' => $app->uuid,
+ ])
+ ->values()
+ ->all();
+
+ $this->stack_databases = $this->resource->databases
+ ->map(fn ($db) => [
+ 'name' => $db->human_name ?: $db->name,
+ 'uuid' => $db->uuid,
+ ])
+ ->values()
+ ->all();
+ }
+ }
+
+ private function resolveServer()
+ {
+ try {
+ if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
+ return $this->resource->destination->server;
+ }
+ if (method_exists($this->resource, 'server') && $this->resource->server) {
+ return $this->resource->server;
+ }
+ } catch (\Throwable $e) {
+ return null;
+ }
+
+ return null;
+ }
+
+ public function render()
+ {
+ return view('livewire.project.shared.resource-details');
+ }
+}
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..2a8747c33 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;
@@ -49,10 +58,9 @@ class ResourceOperations extends Component
{
$this->authorize('update', $this->resource);
- $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
- $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
- $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
@@ -60,7 +68,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 +79,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 +141,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $new_resource->id,
@@ -254,9 +263,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 +280,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $application->id,
@@ -296,9 +306,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 +323,7 @@ class ResourceOperations extends Component
'id',
'created_at',
'updated_at',
+ 'uuid',
])->fill([
'name' => $newName,
'resource_id' => $database->id,
@@ -354,9 +365,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/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index eee5a0776..2aaca5e6f 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\LocalPersistentVolume;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -31,19 +32,33 @@ class Show extends Component
public bool $isPreviewSuffixEnabled = true;
- protected $rules = [
- 'name' => 'required|string',
- 'mountPath' => 'required|string',
- 'hostPath' => 'string|nullable',
- 'isPreviewSuffixEnabled' => 'required|boolean',
- ];
-
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
'hostPath' => 'host',
];
+ protected function rules(): array
+ {
+ return [
+ 'name' => ValidationPatterns::volumeNameRules(),
+ 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'isPreviewSuffixEnabled' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return array_merge(
+ ValidationPatterns::volumeNameMessages(),
+ [
+ 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
+ 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
+ ]
+ );
+ }
+
/**
* Sync data between component properties and model
*
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index bbc2b3e66..db65cdaad 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -12,6 +12,8 @@ class Terminal extends Component
{
public bool $hasShell = true;
+ public bool $isTerminalConnected = false;
+
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
@@ -65,12 +67,20 @@ class Terminal extends Component
$dockerCommand = "sudo {$dockerCommand}";
}
- $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
+ $command = SshMultiplexingHelper::generateSshCommand(
+ $server,
+ $dockerCommand,
+ commandTimeout: (int) config('constants.terminal.command_timeout')
+ );
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
- $command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
+ $command = SshMultiplexingHelper::generateSshCommand(
+ $server,
+ $shellCommand,
+ commandTimeout: (int) config('constants.terminal.command_timeout')
+ );
}
// ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here
@@ -84,6 +94,23 @@ class Terminal extends Component
$this->dispatch('send-back-command', $command);
}
+ #[On('terminalConnected')]
+ public function markTerminalConnected(): void
+ {
+ $this->isTerminalConnected = true;
+ }
+
+ #[On('terminalDisconnected')]
+ public function markTerminalDisconnected(): void
+ {
+ $this->isTerminalConnected = false;
+ }
+
+ public function keepTerminalPageAlive(): void
+ {
+ $this->isTerminalConnected = true;
+ }
+
public function render()
{
return view('livewire.project.shared.terminal');
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index a263acedf..c275ec097 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Laravel\Sanctum\PersonalAccessToken;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class ApiTokens extends Component
@@ -13,14 +14,26 @@ class ApiTokens extends Component
public ?string $description = null;
+ public ?int $expiresInDays = 30;
+
public $tokens = [];
public array $permissions = ['read'];
+ public array $expirationOptions = [
+ 7 => '7 days',
+ 30 => '30 days',
+ 60 => '60 days',
+ 90 => '90 days',
+ 365 => '1 year',
+ ];
+
public $isApiEnabled;
+ #[Locked]
public bool $canUseRootPermissions = false;
+ #[Locked]
public bool $canUseWritePermissions = false;
public function render()
@@ -44,7 +57,7 @@ class ApiTokens extends Component
public function updatedPermissions($permissionToUpdate)
{
// Check if user is trying to use restricted permissions
- if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
+ if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
// Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']);
@@ -52,7 +65,7 @@ class ApiTokens extends Component
return;
}
- if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
+ if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
// Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
@@ -62,7 +75,7 @@ class ApiTokens extends Component
if ($permissionToUpdate == 'root') {
$this->permissions = ['root'];
- } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) {
+ } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} elseif ($permissionToUpdate == 'deploy') {
$this->permissions = ['deploy'];
@@ -80,18 +93,20 @@ class ApiTokens extends Component
$this->authorize('create', PersonalAccessToken::class);
// Validate permissions based on user role
- if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
+ if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
- if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
+ if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
$this->validate([
'description' => 'required|min:3|max:255',
+ 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
- $token = auth()->user()->createToken($this->description, array_values($this->permissions));
+ $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
+ $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$this->getTokens();
session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {
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/Destinations.php b/app/Livewire/Server/Destinations.php
index 117b43ad6..f3f142646 100644
--- a/app/Livewire/Server/Destinations.php
+++ b/app/Livewire/Server/Destinations.php
@@ -45,7 +45,7 @@ class Destinations extends Component
} else {
SwarmDocker::create([
'name' => $this->server->name.'-'.$name,
- 'network' => $this->name,
+ 'network' => $name,
'server_id' => $this->server->id,
]);
}
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/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
index c67591cf5..20d14ddc7 100644
--- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
+++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
@@ -28,12 +28,11 @@ class DynamicConfigurationNavbar extends Component
// Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
- // This must happen BEFORE validation because validateShellSafePath() correctly
- // rejects pipe characters as dangerous shell metacharacters
+ // This must happen BEFORE validation because validateFilenameSafe()
+ // rejects pipe characters through validateShellSafePath().
$file = str_replace('|', '.', $fileName);
- // Validate filename to prevent command injection
- validateShellSafePath($file, 'proxy configuration filename');
+ validateFilenameSafe($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index 31a1dfc7e..481d89c78 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -43,8 +43,7 @@ class NewDynamicConfiguration extends Component
'value' => 'required',
]);
- // Additional security validation to prevent command injection
- validateShellSafePath($this->fileName, 'proxy configuration filename');
+ validateFilenameSafe($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php
index dff379ae1..06aebd8f8 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;
@@ -93,7 +93,9 @@ class Sentinel extends Component
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
- $this->syncData();
+ // Only refresh display-only state; never re-sync text-input properties
+ // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
+ $this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 84cb65ee6..d7339dcdb 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -32,6 +32,8 @@ class Show extends Component
public string $port;
+ public int $connectionTimeout;
+
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@@ -110,6 +112,7 @@ class Show extends Component
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
+ 'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@@ -138,6 +141,10 @@ class Show extends Component
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
+ 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
+ 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+ 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
+ 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@@ -210,6 +217,7 @@ class Show extends Component
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
+ $this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@@ -237,6 +245,7 @@ class Show extends Component
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
+ $this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@@ -268,7 +277,9 @@ class Show extends Component
// Only refresh if the event is for this server
if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
- $this->syncData();
+ // Only refresh display-only state; never re-sync text-input properties
+ // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
+ $this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
@@ -407,7 +418,7 @@ class Show extends Component
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@@ -448,12 +459,15 @@ class Show extends Component
return;
}
- // Refresh server data
+ // Refresh server data and only the display-only state that validation produces.
+ // Never re-sync text-input properties via syncData() — would clobber any
+ // unsaved typing (see coolify#6062 / #6354 / #9695).
$this->server->refresh();
- $this->syncData();
-
- // Update validation state
+ $this->server->settings->refresh();
$this->isValidating = $this->server->is_validating ?? false;
+ $this->validationLogs = $this->server->validation_logs;
+ $this->isReachable = $this->server->settings->is_reachable;
+ $this->isUsable = $this->server->settings->is_usable;
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();
@@ -471,7 +485,7 @@ class Show extends Component
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
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 +1990,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})