diff --git a/.ai/design-system.md b/.ai/design-system.md
deleted file mode 100644
index d22adf3c6..000000000
--- a/.ai/design-system.md
+++ /dev/null
@@ -1,1666 +0,0 @@
-# Coolify Design System
-
-> **Purpose**: AI/LLM-consumable reference for replicating Coolify's visual design in new applications. Contains design tokens, component styles, and interactive states — with both Tailwind CSS classes and plain CSS equivalents.
-
----
-
-## 1. Design Tokens
-
-### 1.1 Colors
-
-#### Brand / Accent
-
-| Token | Hex | Usage |
-|---|---|---|
-| `coollabs` | `#6b16ed` | Primary accent (light mode) |
-| `coollabs-50` | `#f5f0ff` | Highlighted button bg (light) |
-| `coollabs-100` | `#7317ff` | Highlighted button hover (dark) |
-| `coollabs-200` | `#5a12c7` | Highlighted button text (light) |
-| `coollabs-300` | `#4a0fa3` | Deepest brand shade |
-| `warning` / `warning-400` | `#fcd452` | Primary accent (dark mode) |
-
-#### Warning Scale (used for dark-mode accent + callouts)
-
-| Token | Hex |
-|---|---|
-| `warning-50` | `#fefce8` |
-| `warning-100` | `#fef9c3` |
-| `warning-200` | `#fef08a` |
-| `warning-300` | `#fde047` |
-| `warning-400` | `#fcd452` |
-| `warning-500` | `#facc15` |
-| `warning-600` | `#ca8a04` |
-| `warning-700` | `#a16207` |
-| `warning-800` | `#854d0e` |
-| `warning-900` | `#713f12` |
-
-#### Neutral Grays (dark mode backgrounds)
-
-| Token | Hex | Usage |
-|---|---|---|
-| `base` | `#101010` | Page background (dark) |
-| `coolgray-100` | `#181818` | Component background (dark) |
-| `coolgray-200` | `#202020` | Elevated surface / borders (dark) |
-| `coolgray-300` | `#242424` | Input border shadow / hover (dark) |
-| `coolgray-400` | `#282828` | Tooltip background (dark) |
-| `coolgray-500` | `#323232` | Subtle hover overlays (dark) |
-
-#### Semantic
-
-| Token | Hex | Usage |
-|---|---|---|
-| `success` | `#22C55E` | Running status, success alerts |
-| `error` | `#dc2626` | Stopped status, danger actions, error alerts |
-
-#### Light Mode Defaults
-
-| Element | Color |
-|---|---|
-| Page background | `gray-50` (`#f9fafb`) |
-| Component background | `white` (`#ffffff`) |
-| Borders | `neutral-200` (`#e5e5e5`) |
-| Primary text | `black` (`#000000`) |
-| Muted text | `neutral-500` (`#737373`) |
-| Placeholder text | `neutral-300` (`#d4d4d4`) |
-
-### 1.2 Typography
-
-**Font family**: Inter, sans-serif (weights 100–900, woff2, `font-display: swap`)
-
-#### Heading Hierarchy
-
-> **CRITICAL**: All headings and titles (h1–h4, card titles, modal titles) MUST be `white` (`#fff`) in dark mode. The default body text color is `neutral-400` (`#a3a3a3`) — headings must override this to white or they will be nearly invisible on dark backgrounds.
-
-| Element | Tailwind | Plain CSS (light) | Plain CSS (dark) |
-|---|---|---|---|
-| `h1` | `text-3xl font-bold dark:text-white` | `font-size: 1.875rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h2` | `text-xl font-bold dark:text-white` | `font-size: 1.25rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h3` | `text-lg font-bold dark:text-white` | `font-size: 1.125rem; font-weight: 700; color: #000;` | `color: #fff;` |
-| `h4` | `text-base font-bold dark:text-white` | `font-size: 1rem; font-weight: 700; color: #000;` | `color: #fff;` |
-
-#### Body Text
-
-| Context | Tailwind | Plain CSS |
-|---|---|---|
-| Body default | `text-sm antialiased` | `font-size: 0.875rem; line-height: 1.25rem; -webkit-font-smoothing: antialiased;` |
-| Labels | `text-sm font-medium` | `font-size: 0.875rem; font-weight: 500;` |
-| Badge/status text | `text-xs font-bold` | `font-size: 0.75rem; line-height: 1rem; font-weight: 700;` |
-| Box description | `text-xs font-bold text-neutral-500` | `font-size: 0.75rem; font-weight: 700; color: #737373;` |
-
-### 1.3 Spacing Patterns
-
-| Context | Value | CSS |
-|---|---|---|
-| Component internal padding | `p-2` | `padding: 0.5rem;` |
-| Callout padding | `p-4` | `padding: 1rem;` |
-| Input vertical padding | `py-1.5` | `padding-top: 0.375rem; padding-bottom: 0.375rem;` |
-| Button height | `h-8` | `height: 2rem;` |
-| Button horizontal padding | `px-2` | `padding-left: 0.5rem; padding-right: 0.5rem;` |
-| Button gap | `gap-2` | `gap: 0.5rem;` |
-| Menu item padding | `px-2 py-1` | `padding: 0.25rem 0.5rem;` |
-| Menu item gap | `gap-3` | `gap: 0.75rem;` |
-| Section margin | `mb-12` | `margin-bottom: 3rem;` |
-| Card min-height | `min-h-[4rem]` | `min-height: 4rem;` |
-
-### 1.4 Border Radius
-
-| Context | Tailwind | Plain CSS |
-|---|---|---|
-| Default (inputs, buttons, cards, modals) | `rounded-sm` | `border-radius: 0.125rem;` |
-| Callouts | `rounded-lg` | `border-radius: 0.5rem;` |
-| Badges | `rounded-full` | `border-radius: 9999px;` |
-| Cards (coolbox variant) | `rounded` | `border-radius: 0.25rem;` |
-
-### 1.5 Shadows
-
-#### Input / Select Box-Shadow System
-
-Coolify uses **inset box-shadows instead of borders** for inputs and selects. This enables a unique "dirty indicator" — a colored left-edge bar.
-
-```css
-/* Default state */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
-
-/* Default state (dark) */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
-
-/* Focus state (light) — purple left bar */
-box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
-
-/* Focus state (dark) — yellow left bar */
-box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
-
-/* Dirty (modified) state — same as focus */
-box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5; /* light */
-box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424; /* dark */
-
-/* Disabled / Readonly */
-box-shadow: none;
-```
-
-#### Input-Sticky Variant (thinner border)
-
-```css
-/* Uses 1px border instead of 2px */
-box-shadow: inset 4px 0 0 transparent, inset 0 0 0 1px #e5e5e5;
-```
-
-### 1.6 Focus Ring System
-
-All interactive elements (buttons, links, checkboxes) share this focus pattern:
-
-**Tailwind:**
-```
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #6b16ed; /* light */
-}
-
-/* dark mode */
-.dark :focus-visible {
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-```
-
-> **Note**: Inputs use the inset box-shadow system (section 1.5) instead of the ring system.
-
----
-
-## 2. Dark Mode Strategy
-
-- **Toggle method**: Class-based — `.dark` class on `` element
-- **CSS variant**: `@custom-variant dark (&:where(.dark, .dark *));`
-- **Default border override**: All elements default to `border-color: var(--color-coolgray-200)` (`#202020`) instead of `currentcolor`
-
-### Accent Color Swap
-
-| Context | Light | Dark |
-|---|---|---|
-| Primary accent | `coollabs` (`#6b16ed`) | `warning` (`#fcd452`) |
-| Focus ring | `ring-coollabs` | `ring-warning` |
-| Input focus bar | `#6b16ed` (purple) | `#fcd452` (yellow) |
-| Active nav text | `text-black` | `text-warning` |
-| Helper/highlight text | `text-coollabs` | `text-warning` |
-| Loading spinner | `text-coollabs` | `text-warning` |
-| Scrollbar thumb | `coollabs-100` | `coollabs-100` |
-
-### Background Hierarchy (dark)
-
-```
-#101010 (base) — page background
- └─ #181818 (coolgray-100) — cards, inputs, components
- └─ #202020 (coolgray-200) — elevated surfaces, borders, nav active
- └─ #242424 (coolgray-300) — input borders (via box-shadow), button borders
- └─ #282828 (coolgray-400) — tooltips, hover states
- └─ #323232 (coolgray-500) — subtle overlays
-```
-
-### Background Hierarchy (light)
-
-```
-#f9fafb (gray-50) — page background
- └─ #ffffff (white) — cards, inputs, components
- └─ #e5e5e5 (neutral-200) — borders
- └─ #f5f5f5 (neutral-100) — hover backgrounds
- └─ #d4d4d4 (neutral-300) — deeper hover, nav active
-```
-
----
-
-## 3. Component Catalog
-
-### 3.1 Button
-
-#### Default
-
-**Tailwind:**
-```
-flex gap-2 justify-center items-center px-2 h-8 text-sm text-black normal-case rounded-sm
-border-2 outline-0 cursor-pointer font-medium bg-white border-neutral-200 hover:bg-neutral-100
-dark:bg-coolgray-100 dark:text-white dark:hover:text-white dark:hover:bg-coolgray-200
-dark:border-coolgray-300 hover:text-black disabled:cursor-not-allowed min-w-fit
-dark:disabled:text-neutral-600 disabled:border-transparent disabled:hover:bg-transparent
-disabled:bg-transparent disabled:text-neutral-300
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs
-dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-.button {
- display: flex;
- gap: 0.5rem;
- justify-content: center;
- align-items: center;
- padding: 0 0.5rem;
- height: 2rem;
- font-size: 0.875rem;
- font-weight: 500;
- text-transform: none;
- color: #000;
- background: #fff;
- border: 2px solid #e5e5e5;
- border-radius: 0.125rem;
- outline: 0;
- cursor: pointer;
- min-width: fit-content;
-}
-.button:hover { background: #f5f5f5; }
-
-/* Dark */
-.dark .button {
- background: #181818;
- color: #fff;
- border-color: #242424;
-}
-.dark .button:hover {
- background: #202020;
- color: #fff;
-}
-
-/* Disabled */
-.button:disabled {
- cursor: not-allowed;
- border-color: transparent;
- background: transparent;
- color: #d4d4d4;
-}
-.dark .button:disabled { color: #525252; }
-```
-
-#### Highlighted (Primary Action)
-
-**Tailwind** (via `isHighlighted` attribute):
-```
-text-coollabs-200 dark:text-white bg-coollabs-50 dark:bg-coollabs/20
-border-coollabs dark:border-coollabs-100 hover:bg-coollabs hover:text-white
-dark:hover:bg-coollabs-100 dark:hover:text-white
-```
-
-**Plain CSS:**
-```css
-.button-highlighted {
- color: #5a12c7;
- background: #f5f0ff;
- border-color: #6b16ed;
-}
-.button-highlighted:hover {
- background: #6b16ed;
- color: #fff;
-}
-.dark .button-highlighted {
- color: #fff;
- background: rgba(107, 22, 237, 0.2);
- border-color: #7317ff;
-}
-.dark .button-highlighted:hover {
- background: #7317ff;
- color: #fff;
-}
-```
-
-#### Error / Danger
-
-**Tailwind** (via `isError` attribute):
-```
-text-red-800 dark:text-red-300 bg-red-50 dark:bg-red-900/30
-border-red-300 dark:border-red-800 hover:bg-red-300 hover:text-white
-dark:hover:bg-red-800 dark:hover:text-white
-```
-
-**Plain CSS:**
-```css
-.button-error {
- color: #991b1b;
- background: #fef2f2;
- border-color: #fca5a5;
-}
-.button-error:hover {
- background: #fca5a5;
- color: #fff;
-}
-.dark .button-error {
- color: #fca5a5;
- background: rgba(127, 29, 29, 0.3);
- border-color: #991b1b;
-}
-.dark .button-error:hover {
- background: #991b1b;
- color: #fff;
-}
-```
-
-#### Loading Indicator
-
-Buttons automatically show a spinner (SVG with `animate-spin`) next to their content during async operations. The spinner uses the accent color (`text-coollabs` / `text-warning`).
-
----
-
-### 3.2 Input
-
-**Tailwind:**
-```
-block py-1.5 w-full text-sm text-black rounded-sm border-0
-dark:bg-coolgray-100 dark:text-white
-disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40
-dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40
-placeholder:text-neutral-300 dark:placeholder:text-neutral-700
-read-only:text-neutral-500 read-only:bg-neutral-200
-focus-visible:outline-none
-```
-
-**Plain CSS:**
-```css
-.input {
- display: block;
- padding: 0.375rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- color: #000;
- background: #fff;
- border: 0;
- border-radius: 0.125rem;
- box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
-}
-.input:focus-visible {
- outline: none;
- box-shadow: inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
-}
-.input::placeholder { color: #d4d4d4; }
-.input:disabled { background: #e5e5e5; color: #737373; box-shadow: none; }
-.input:read-only { color: #737373; background: #e5e5e5; box-shadow: none; }
-.input[type="password"] { padding-right: 2.4rem; }
-
-/* Dark */
-.dark .input {
- background: #181818;
- color: #fff;
- box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
-}
-.dark .input:focus-visible {
- box-shadow: inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
-}
-.dark .input::placeholder { color: #404040; }
-.dark .input:disabled { background: rgba(24, 24, 24, 0.4); box-shadow: none; }
-.dark .input:read-only { color: #737373; background: rgba(24, 24, 24, 0.4); box-shadow: none; }
-```
-
-#### Dirty (Modified) State
-
-When an input value has been changed but not saved, a 4px colored left bar appears via box-shadow — same colors as focus state. This provides a visual indicator that the field has unsaved changes.
-
----
-
-### 3.3 Select
-
-Same base styles as Input, plus a custom dropdown arrow SVG:
-
-**Tailwind:**
-```
-w-full block py-1.5 text-sm text-black rounded-sm border-0
-dark:bg-coolgray-100 dark:text-white
-disabled:bg-neutral-200 disabled:text-neutral-500 dark:disabled:bg-coolgray-100/40
-focus-visible:outline-none
-```
-
-**Additional plain CSS for the dropdown arrow:**
-```css
-.select {
- /* ...same as .input base... */
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23000000'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
- background-position: right 0.5rem center;
- background-repeat: no-repeat;
- background-size: 1rem 1rem;
- padding-right: 2.5rem;
- appearance: none;
-}
-
-/* Dark mode: white stroke arrow */
-.dark .select {
- background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke-width='1.5' stroke='%23ffffff'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' d='M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9'/%3e%3c/svg%3e");
-}
-```
-
----
-
-### 3.4 Checkbox
-
-**Tailwind:**
-```
-dark:border-neutral-700 text-coolgray-400 dark:bg-coolgray-100 rounded-sm cursor-pointer
-dark:disabled:bg-base dark:disabled:cursor-not-allowed
-focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs
-dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Container:**
-```
-flex flex-row items-center gap-4 pr-2 py-1 form-control min-w-fit
-dark:hover:bg-coolgray-100 cursor-pointer
-```
-
-**Plain CSS:**
-```css
-.checkbox {
- border-color: #404040;
- color: #282828;
- background: #181818;
- border-radius: 0.125rem;
- cursor: pointer;
-}
-.checkbox:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-
-.checkbox-container {
- display: flex;
- flex-direction: row;
- align-items: center;
- gap: 1rem;
- padding: 0.25rem 0.5rem 0.25rem 0;
- min-width: fit-content;
- cursor: pointer;
-}
-.dark .checkbox-container:hover { background: #181818; }
-```
-
----
-
-### 3.5 Textarea
-
-Uses `font-mono` for monospace text. Supports tab key insertion (2 spaces).
-
-**Important**: Large/multiline textareas should NOT use the inset box-shadow left-border system from `.input`. Use a simple border instead:
-
-**Tailwind:**
-```
-block w-full text-sm text-black rounded-sm border border-neutral-200
-dark:bg-coolgray-100 dark:text-white dark:border-coolgray-300
-font-mono focus-visible:outline-none focus-visible:ring-2
-focus-visible:ring-coollabs dark:focus-visible:ring-warning
-focus-visible:ring-offset-2 dark:focus-visible:ring-offset-base
-```
-
-**Plain CSS:**
-```css
-.textarea {
- display: block;
- width: 100%;
- font-size: 0.875rem;
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
- color: #000;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
-}
-.textarea:focus-visible {
- outline: none;
- box-shadow: 0 0 0 2px #fff, 0 0 0 4px #6b16ed;
-}
-.dark .textarea {
- background: #181818;
- color: #fff;
- border-color: #242424;
-}
-.dark .textarea:focus-visible {
- box-shadow: 0 0 0 2px #101010, 0 0 0 4px #fcd452;
-}
-```
-
-> **Note**: The 4px inset left-border (dirty/focus indicator) is only for single-line inputs and selects, not textareas.
-
----
-
-### 3.6 Box / Card
-
-#### Standard Box
-
-**Tailwind:**
-```
-relative flex lg:flex-row flex-col p-2 transition-colors cursor-pointer min-h-[4rem]
-dark:bg-coolgray-100 shadow-sm bg-white border text-black dark:text-white hover:text-black
-border-neutral-200 dark:border-coolgray-300 hover:bg-neutral-100
-dark:hover:bg-coollabs-100 dark:hover:text-white hover:no-underline rounded-sm
-```
-
-**Plain CSS:**
-```css
-.box {
- position: relative;
- display: flex;
- flex-direction: column;
- padding: 0.5rem;
- min-height: 4rem;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.125rem;
- box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
- color: #000;
- cursor: pointer;
- transition: background-color 150ms, color 150ms;
- text-decoration: none;
-}
-.box:hover { background: #f5f5f5; color: #000; }
-
-.dark .box {
- background: #181818;
- border-color: #242424;
- color: #fff;
-}
-.dark .box:hover {
- background: #7317ff;
- color: #fff;
-}
-
-/* IMPORTANT: child text must also turn white/black on hover,
- since description text (#737373) is invisible on purple bg */
-.box:hover .box-title { color: #000; }
-.box:hover .box-description { color: #000; }
-.dark .box:hover .box-title { color: #fff; }
-.dark .box:hover .box-description { color: #fff; }
-
-/* Desktop: row layout */
-@media (min-width: 1024px) {
- .box { flex-direction: row; }
-}
-```
-
-#### Coolbox (Ring Hover)
-
-**Tailwind:**
-```
-relative flex transition-all duration-150 dark:bg-coolgray-100 bg-white p-2 rounded
-border border-neutral-200 dark:border-coolgray-400 hover:ring-2
-dark:hover:ring-warning hover:ring-coollabs cursor-pointer min-h-[4rem]
-```
-
-**Plain CSS:**
-```css
-.coolbox {
- position: relative;
- display: flex;
- padding: 0.5rem;
- min-height: 4rem;
- background: #fff;
- border: 1px solid #e5e5e5;
- border-radius: 0.25rem;
- cursor: pointer;
- transition: all 150ms;
-}
-.coolbox:hover { box-shadow: 0 0 0 2px #6b16ed; }
-
-.dark .coolbox {
- background: #181818;
- border-color: #282828;
-}
-.dark .coolbox:hover { box-shadow: 0 0 0 2px #fcd452; }
-```
-
-#### Box Text
-
-> **IMPORTANT — Dark mode titles**: Card/box titles MUST be `#fff` (white) in dark mode, not the default body text color (`#a3a3a3` / neutral-400). A black or grey title is nearly invisible on dark backgrounds (`#181818`). This applies to all heading-level text inside cards.
-
-```css
-.box-title {
- font-weight: 700;
- color: #000; /* light mode: black */
-}
-.dark .box-title {
- color: #fff; /* dark mode: MUST be white, not grey */
-}
-
-.box-description {
- font-size: 0.75rem;
- font-weight: 700;
- color: #737373;
-}
-/* On hover: description must become visible against colored bg */
-.box:hover .box-description { color: #000; }
-.dark .box:hover .box-description { color: #fff; }
-```
-
----
-
-### 3.7 Badge / Status Indicator
-
-**Tailwind:**
-```
-inline-block w-3 h-3 text-xs font-bold rounded-full leading-none
-border border-neutral-200 dark:border-black
-```
-
-**Variants**: `badge-success` (`bg-success`), `badge-warning` (`bg-warning`), `badge-error` (`bg-error`)
-
-**Plain CSS:**
-```css
-.badge {
- display: inline-block;
- width: 0.75rem;
- height: 0.75rem;
- border-radius: 9999px;
- border: 1px solid #e5e5e5;
-}
-.dark .badge { border-color: #000; }
-
-.badge-success { background: #22C55E; }
-.badge-warning { background: #fcd452; }
-.badge-error { background: #dc2626; }
-```
-
-#### Status Text Pattern
-
-Status indicators combine a badge dot with text:
-
-```html
-
-```
-
-| Status | Badge Class | Text Color |
-|---|---|---|
-| Running | `badge-success` | `text-success` (`#22C55E`) |
-| Stopped | `badge-error` | `text-error` (`#dc2626`) |
-| Degraded | `badge-warning` | `dark:text-warning` (`#fcd452`) |
-| Restarting | `badge-warning` | `dark:text-warning` (`#fcd452`) |
-
----
-
-### 3.8 Dropdown
-
-**Container Tailwind:**
-```
-p-1 mt-1 bg-white border rounded-sm shadow-sm
-dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300
-```
-
-**Item Tailwind:**
-```
-flex relative gap-2 justify-start items-center py-1 pr-4 pl-2 w-full text-xs
-transition-colors cursor-pointer select-none dark:text-white
-hover:bg-neutral-100 dark:hover:bg-coollabs
-outline-none focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs
-```
-
-**Plain CSS:**
-```css
-.dropdown {
- padding: 0.25rem;
- margin-top: 0.25rem;
- background: #fff;
- border: 1px solid #d4d4d4;
- border-radius: 0.125rem;
- box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
-}
-.dark .dropdown {
- background: #202020;
- border-color: #242424;
-}
-
-.dropdown-item {
- display: flex;
- position: relative;
- gap: 0.5rem;
- justify-content: flex-start;
- align-items: center;
- padding: 0.25rem 1rem 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.75rem;
- cursor: pointer;
- user-select: none;
- transition: background-color 150ms;
-}
-.dropdown-item:hover { background: #f5f5f5; }
-.dark .dropdown-item { color: #fff; }
-.dark .dropdown-item:hover { background: #6b16ed; }
-```
-
----
-
-### 3.9 Sidebar / Navigation
-
-#### Sidebar Container + Page Layout
-
-The navbar is a **fixed left sidebar** (14rem / 224px wide on desktop), with main content offset to the right.
-
-**Tailwind (sidebar wrapper — desktop):**
-```
-hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0
-```
-
-**Tailwind (sidebar inner — scrollable):**
-```
-flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0
-```
-
-**Tailwind (nav element):**
-```
-flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base
-```
-
-**Tailwind (main content area):**
-```
-lg:pl-56
-```
-
-**Tailwind (main content padding):**
-```
-p-4 sm:px-6 lg:px-8 lg:py-6
-```
-
-**Tailwind (mobile top bar — shown on small screens, hidden on lg+):**
-```
-sticky top-0 z-40 flex items-center justify-between px-4 py-4 gap-x-6 sm:px-6 lg:hidden
-bg-white/95 dark:bg-base/95 backdrop-blur-sm border-b border-neutral-300/50 dark:border-coolgray-200/50
-```
-
-**Tailwind (mobile hamburger icon):**
-```
--m-2.5 p-2.5 dark:text-warning
-```
-
-**Plain CSS:**
-```css
-/* Sidebar — desktop only */
-.sidebar {
- display: none;
-}
-@media (min-width: 1024px) {
- .sidebar {
- display: flex;
- flex-direction: column;
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: 50;
- width: 14rem; /* 224px */
- min-width: 0;
- }
-}
-
-.sidebar-inner {
- display: flex;
- flex-direction: column;
- flex-grow: 1;
- overflow-y: auto;
- gap: 1.25rem;
- min-width: 0;
-}
-
-/* Nav element */
-.sidebar-nav {
- display: flex;
- flex-direction: column;
- flex: 1;
- padding: 0 0.5rem;
- background: #fff;
- border-right: 1px solid #d4d4d4;
-}
-.dark .sidebar-nav {
- background: #101010;
- border-right-color: #202020;
-}
-
-/* Main content offset */
-@media (min-width: 1024px) {
- .main-content { padding-left: 14rem; }
-}
-
-.main-content-inner {
- padding: 1rem;
-}
-@media (min-width: 640px) {
- .main-content-inner { padding: 1rem 1.5rem; }
-}
-@media (min-width: 1024px) {
- .main-content-inner { padding: 1.5rem 2rem; }
-}
-
-/* Mobile top bar — visible below lg breakpoint */
-.mobile-topbar {
- position: sticky;
- top: 0;
- z-index: 40;
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 1rem;
- gap: 1.5rem;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(12px);
- border-bottom: 1px solid rgba(212, 212, 212, 0.5);
-}
-.dark .mobile-topbar {
- background: rgba(16, 16, 16, 0.95);
- border-bottom-color: rgba(32, 32, 32, 0.5);
-}
-@media (min-width: 1024px) {
- .mobile-topbar { display: none; }
-}
-
-/* Mobile sidebar overlay (shown when hamburger is tapped) */
-.sidebar-mobile {
- position: relative;
- display: flex;
- flex: 1;
- width: 100%;
- max-width: 14rem;
- min-width: 0;
-}
-.sidebar-mobile-scroll {
- display: flex;
- flex-direction: column;
- padding-bottom: 0.5rem;
- overflow-y: auto;
- min-width: 14rem;
- gap: 1.25rem;
- min-width: 0;
-}
-.dark .sidebar-mobile-scroll { background: #181818; }
-```
-
-#### Sidebar Header (Logo + Search)
-
-**Tailwind:**
-```
-flex lg:pt-6 pt-4 pb-4 pl-2
-```
-
-**Logo:**
-```
-text-2xl font-bold tracking-wide dark:text-white hover:opacity-80 transition-opacity
-```
-
-**Search button:**
-```
-flex items-center gap-1.5 px-2.5 py-1.5
-bg-neutral-100 dark:bg-coolgray-100
-border border-neutral-300 dark:border-coolgray-200
-rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors
-```
-
-**Search kbd hint:**
-```
-px-1 py-0.5 text-xs font-semibold
-text-neutral-500 dark:text-neutral-400
-bg-neutral-200 dark:bg-coolgray-200 rounded
-```
-
-**Plain CSS:**
-```css
-.sidebar-header {
- display: flex;
- padding: 1rem 0 1rem 0.5rem;
-}
-@media (min-width: 1024px) {
- .sidebar-header { padding-top: 1.5rem; }
-}
-
-.sidebar-logo {
- font-size: 1.5rem;
- font-weight: 700;
- letter-spacing: 0.025em;
- color: #000;
- text-decoration: none;
-}
-.dark .sidebar-logo { color: #fff; }
-.sidebar-logo:hover { opacity: 0.8; }
-
-.sidebar-search-btn {
- display: flex;
- align-items: center;
- gap: 0.375rem;
- padding: 0.375rem 0.625rem;
- background: #f5f5f5;
- border: 1px solid #d4d4d4;
- border-radius: 0.375rem;
- cursor: pointer;
- transition: background-color 150ms;
-}
-.sidebar-search-btn:hover { background: #e5e5e5; }
-.dark .sidebar-search-btn {
- background: #181818;
- border-color: #202020;
-}
-.dark .sidebar-search-btn:hover { background: #202020; }
-
-.sidebar-search-kbd {
- padding: 0.125rem 0.25rem;
- font-size: 0.75rem;
- font-weight: 600;
- color: #737373;
- background: #e5e5e5;
- border-radius: 0.25rem;
-}
-.dark .sidebar-search-kbd {
- color: #a3a3a3;
- background: #202020;
-}
-```
-
-#### Menu Item List
-
-**Tailwind (list container):**
-```
-flex flex-col flex-1 gap-y-7
-```
-
-**Tailwind (inner list):**
-```
-flex flex-col h-full space-y-1.5
-```
-
-**Plain CSS:**
-```css
-.menu-list {
- display: flex;
- flex-direction: column;
- flex: 1;
- gap: 1.75rem;
- list-style: none;
- padding: 0;
- margin: 0;
-}
-
-.menu-list-inner {
- display: flex;
- flex-direction: column;
- height: 100%;
- gap: 0.375rem;
- list-style: none;
- padding: 0;
- margin: 0;
-}
-```
-
-#### Menu Item
-
-**Tailwind:**
-```
-flex gap-3 items-center px-2 py-1 w-full text-sm
-dark:hover:bg-coolgray-100 dark:hover:text-white hover:bg-neutral-300 rounded-sm truncate min-w-0
-```
-
-#### Menu Item Active
-
-**Tailwind:**
-```
-text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden
-```
-
-#### Menu Item Icon / Label
-
-```
-/* Icon */ flex-shrink-0 w-6 h-6 dark:hover:text-white
-/* Label */ min-w-0 flex-1 truncate
-```
-
-**Plain CSS:**
-```css
-.menu-item {
- display: flex;
- gap: 0.75rem;
- align-items: center;
- padding: 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- border-radius: 0.125rem;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.menu-item:hover { background: #d4d4d4; }
-.dark .menu-item:hover { background: #181818; color: #fff; }
-
-.menu-item-active {
- color: #000;
- background: #e5e5e5;
- border-radius: 0.125rem;
-}
-.dark .menu-item-active {
- background: #202020;
- color: #fcd452;
-}
-
-.menu-item-icon {
- flex-shrink: 0;
- width: 1.5rem;
- height: 1.5rem;
-}
-
-.menu-item-label {
- min-width: 0;
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-```
-
-#### Sub-Menu Item
-
-```css
-.sub-menu-item {
- /* Same as menu-item but with gap: 0.5rem and icon size 1rem */
- display: flex;
- gap: 0.5rem;
- align-items: center;
- padding: 0.25rem 0.5rem;
- width: 100%;
- font-size: 0.875rem;
- border-radius: 0.125rem;
-}
-.sub-menu-item-icon { flex-shrink: 0; width: 1rem; height: 1rem; }
-```
-
----
-
-### 3.10 Callout / Alert
-
-Four types: `warning`, `danger`, `info`, `success`.
-
-**Structure:**
-```html
-
-```
-
-**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/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/CLAUDE.md b/CLAUDE.md
index 5f9c48894..6224f8d66 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,10 @@ ## Project Overview
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.
diff --git a/README.md b/README.md
index 9a5feff4e..28fc33d6f 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
-*
+* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control
### Big Sponsors
@@ -69,6 +69,7 @@ ### Big Sponsors
* [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
+* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [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
@@ -87,6 +88,7 @@ ### Big Sponsors
* [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
@@ -151,6 +153,10 @@ ### Small Sponsors
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 393906b9b..30cae71f1 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
+ 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index cd820523d..addc30be4 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
+ 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index fe80a7d54..e59d6f697 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
+ 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database)
$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'] ?? [],
[
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 498ba0b0b..ceb1e8b85 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database)
);
}
- 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'],
[
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 9565990c1..c79789718 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -340,7 +340,10 @@ private function add_custom_mongo_conf()
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..0394d50b6 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database)
);
}
- 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'] ?? [],
[
@@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database)
$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..da8b5dc4e 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database)
],
'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",
- ],
+ 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@@ -227,7 +224,8 @@ public function handle(StandalonePostgresql $database)
$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 +302,18 @@ private function generate_init_scripts()
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..c31b099e4 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database)
);
}
- 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',
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 98cce088b..33558c746 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'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 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/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 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$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/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 @@ public function handle()
$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 @@ public function handle()
$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..75ec31ae0 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -2,6 +2,7 @@
namespace App\Console;
+use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
@@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$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
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 @@
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 @@ protected function unauthenticated($request, AuthenticationException $exception)
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..4629df571 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$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');
@@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
$scp_command .= '-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);
+ $scp_command .= 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 {
@@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
- $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
+ $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
@@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
}
}
+ 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');
+ }
+
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index b6fdfca47..7bc9168b8 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -5,7 +5,6 @@
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;
@@ -18,7 +17,6 @@
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
-use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
@@ -159,7 +157,7 @@ public function applications(Request $request)
'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.'],
@@ -328,7 +326,7 @@ public function create_public_application(Request $request)
'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.'],
@@ -494,7 +492,7 @@ public function create_private_gh_app_application(Request $request)
'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.'],
@@ -656,7 +654,7 @@ public function create_private_deploy_key_application(Request $request)
'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.'],
@@ -903,105 +901,6 @@ public function create_dockerimage_application(Request $request)
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();
@@ -1313,6 +1212,15 @@ private function create_application(Request $request, $type)
}
}
+ 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'),
@@ -1543,6 +1451,15 @@ private function create_application(Request $request, $type)
}
}
+ 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'),
@@ -1743,6 +1660,15 @@ private function create_application(Request $request, $type)
}
}
+ 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'),
@@ -1850,6 +1776,15 @@ private function create_application(Request $request, $type)
}
}
+ 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'),
@@ -1960,93 +1895,19 @@ private function create_application(Request $request, $type)
}
}
+ 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 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->only($allowedFields));
-
- $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);
@@ -2301,6 +2162,12 @@ public function delete_by_uuid(Request $request)
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.',
]);
@@ -2343,7 +2210,7 @@ public function delete_by_uuid(Request $request)
'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.'],
@@ -2800,6 +2667,13 @@ public function update_by_uuid(Request $request)
}
$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;
@@ -3052,6 +2926,14 @@ public function update_env_by_uuid(Request $request)
}
$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([
@@ -3085,6 +2967,14 @@ public function update_env_by_uuid(Request $request)
}
$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([
@@ -3311,6 +3201,12 @@ public function create_bulk_envs(Request $request)
$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);
}
@@ -3450,6 +3346,14 @@ public function create_env(Request $request)
'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);
@@ -3475,6 +3379,14 @@ public function create_env(Request $request)
'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);
@@ -3566,8 +3478,17 @@ public function delete_env_by_uuid(Request $request)
'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.',
]);
@@ -3679,6 +3600,15 @@ public function action_deploy(Request $request)
);
}
+ 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.',
@@ -3767,6 +3697,13 @@ public function action_stop(Request $request)
$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.',
@@ -3857,6 +3794,13 @@ public function action_restart(Request $request)
], 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.',
@@ -4125,7 +4069,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'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',
]);
@@ -4225,6 +4169,15 @@ public function update_storage(Request $request): JsonResponse
$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);
}
@@ -4303,7 +4256,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'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',
@@ -4403,6 +4356,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ 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);
}
@@ -4476,8 +4438,18 @@ public function delete_storage(Request $request): JsonResponse
$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.']);
}
@@ -4547,6 +4519,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$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 5ca7212fd..1dd2e0006 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@
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;
@@ -246,7 +247,7 @@ public function store(Request $request)
$this->authorize('create', [CloudProviderToken::class]);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -288,6 +289,13 @@ public function store(Request $request)
'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);
@@ -357,7 +365,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -392,6 +400,14 @@ public function update(Request $request)
$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,
]);
@@ -468,8 +484,18 @@ public function destroy(Request $request)
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 8241e5fba..dc9b6f5b5 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -379,9 +379,9 @@ public function update_by_uuid(Request $request)
case 'standalone-postgresql':
$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',
@@ -410,20 +410,20 @@ public function update_by_uuid(Request $request)
case 'standalone-clickhouse':
$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', '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', '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')) {
@@ -450,7 +450,7 @@ public function update_by_uuid(Request $request)
case 'standalone-keydb':
$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')) {
@@ -478,10 +478,10 @@ public function update_by_uuid(Request $request)
$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)) {
@@ -508,9 +508,9 @@ public function update_by_uuid(Request $request)
$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)) {
@@ -537,10 +537,10 @@ public function update_by_uuid(Request $request)
case 'standalone-mysql':
$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')) {
@@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
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.',
]);
@@ -639,10 +647,10 @@ public function update_by_uuid(Request $request)
'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],
],
),
@@ -703,10 +711,10 @@ public function create_backup(Request $request)
'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',
]);
@@ -826,6 +834,15 @@ public function create_backup(Request $request)
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.',
@@ -878,10 +895,10 @@ public function create_backup(Request $request)
'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],
],
),
@@ -933,10 +950,10 @@ public function update_backup(Request $request)
'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()) {
@@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
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',
]);
@@ -1724,9 +1749,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($type === NewDatabaseTypes::POSTGRESQL) {
$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',
@@ -1779,12 +1804,25 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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)) {
@@ -1835,14 +1873,24 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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);
@@ -1894,11 +1942,21 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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);
@@ -1950,11 +2008,21 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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);
@@ -1984,7 +2052,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::KEYDB) {
$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);
@@ -2036,12 +2104,22 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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)) {
@@ -2072,14 +2150,24 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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', '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)) {
@@ -2130,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$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);
}
@@ -2214,6 +2312,13 @@ public function delete_by_uuid(Request $request)
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.',
]);
@@ -2326,13 +2431,21 @@ public function delete_backup_by_uuid(Request $request)
$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);
}
}
@@ -2448,11 +2561,19 @@ public function delete_execution_by_uuid(Request $request)
$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);
}
}
@@ -2630,6 +2751,13 @@ public function action_deploy(Request $request)
}
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.',
@@ -2721,6 +2849,14 @@ public function action_stop(Request $request)
$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.',
@@ -2798,6 +2934,13 @@ public function action_restart(Request $request)
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.',
@@ -3014,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
}
$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);
}
@@ -3142,6 +3292,12 @@ public function create_bulk_envs(Request $request)
$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);
}
@@ -3263,6 +3419,13 @@ public function create_env(Request $request)
'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);
}
@@ -3348,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
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.']);
}
@@ -3496,7 +3668,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'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',
@@ -3596,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ 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);
}
@@ -3694,7 +3875,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'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',
]);
@@ -3794,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
$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);
}
@@ -3867,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
$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 6ff06c10a..c93731d68 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
+ 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,
@@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$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:
@@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
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
@@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 014d84b23..150743f99 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -272,6 +272,12 @@ public function create_github_app(Request $request)
$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);
@@ -652,6 +658,13 @@ public function update_github_app(Request $request, $github_app_id)
// 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,
@@ -737,8 +750,16 @@ public function delete_github_app($github_app_id)
], 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',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index ec49162ae..ded23bb80 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\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 @@ public function locations(Request $request)
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 @@ public function serverTypes(Request $request)
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 @@ public function images(Request $request)
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 @@ public function sshKeys(Request $request)
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);
}
}
@@ -551,7 +553,7 @@ public function createServer(Request $request)
$this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -718,9 +720,17 @@ public function createServer(Request $request)
// 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'],
@@ -734,7 +744,7 @@ public function createServer(Request $request)
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 49468b597..f17a4e46b 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ public function enable_api(Request $request)
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,14 +141,130 @@ public function disable_api(Request $request)
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)
{
$data = $request->validate([
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index a068b271d..92f19c7ae 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -266,6 +266,12 @@ public function create_project(Request $request)
'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);
@@ -385,6 +391,13 @@ public function update_project(Request $request)
$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,
@@ -464,8 +477,16 @@ public function delete_project(Request $request)
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.']);
}
@@ -646,6 +667,13 @@ public function create_environment(Request $request)
'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);
@@ -729,8 +757,17 @@ public function delete_environment(Request $request)
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\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 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
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 @@ private function createTask(Request $request, Application|Service $resource): \I
$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 @@ private function updateTask(Request $request, Application|Service $resource): \I
$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 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
- 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 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
- 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 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- 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 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- 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 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- 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 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
- 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 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
- 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 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- 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 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- 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 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- 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 f8641764d..0559bcd99 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -234,6 +234,13 @@ public function create_key(Request $request)
'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);
@@ -336,6 +343,13 @@ public function update_key(Request $request)
$this->authorize('update', $foundKey);
$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,
]))->setStatusCode(201);
@@ -419,8 +433,16 @@ public function delete_key(Request $request)
], 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/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index 9c11faef8..30cafad3f 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -13,6 +13,7 @@
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;
@@ -479,7 +480,7 @@ public function create_server(Request $request)
$this->authorize('create', [ModelsServer::class]);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -566,6 +567,14 @@ public function create_server(Request $request)
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);
@@ -605,6 +614,7 @@ public function create_server(Request $request)
'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.'],
],
),
),
@@ -641,7 +651,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
- $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'];
+ $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)) {
@@ -649,7 +659,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -667,6 +677,7 @@ public function update_server(Request $request)
'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);
@@ -712,7 +723,7 @@ public function update_server(Request $request)
], 422);
}
- $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
+ $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)));
}
@@ -721,6 +732,13 @@ public function update_server(Request $request)
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);
@@ -811,6 +829,9 @@ public function delete_server(Request $request)
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -820,6 +841,14 @@ public function delete_server(Request $request)
$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.']);
}
@@ -886,6 +915,12 @@ public function validate_server(Request $request)
$this->authorize('update', $server);
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 d18b0c202..89c99ff9f 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -490,6 +490,14 @@ public function create_service(Request $request)
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(),
@@ -654,6 +662,14 @@ public function create_service(Request $request)
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(),
@@ -796,6 +812,12 @@ public function delete_by_uuid(Request $request)
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.',
]);
@@ -1050,6 +1072,13 @@ public function update_by_uuid(Request $request)
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(),
@@ -1259,6 +1288,13 @@ public function update_env_by_uuid(Request $request)
}
$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);
}
@@ -1388,6 +1424,12 @@ public function create_bulk_envs(Request $request)
$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);
}
@@ -1510,6 +1552,13 @@ public function create_env(Request $request)
'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);
}
@@ -1595,8 +1644,17 @@ public function delete_env_by_uuid(Request $request)
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.']);
}
@@ -1672,6 +1730,12 @@ public function action_deploy(Request $request)
}
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.',
@@ -1763,6 +1827,13 @@ public function action_stop(Request $request)
$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.',
@@ -1850,6 +1921,13 @@ public function action_restart(Request $request)
$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.',
@@ -2022,7 +2100,7 @@ public function create_storage(Request $request): JsonResponse
'resource_uuid' => 'required|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',
@@ -2130,6 +2208,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ 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);
}
@@ -2231,7 +2318,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'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',
]);
@@ -2358,6 +2445,15 @@ public function update_storage(Request $request): JsonResponse
$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);
}
@@ -2458,8 +2554,18 @@ public function delete_storage(Request $request): JsonResponse
$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/Controller.php b/app/Http/Controllers/Controller.php
index 17d14296b..6ce6b6d57 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -6,8 +6,8 @@
use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
+use Illuminate\Auth\Events\Verified;
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 +39,29 @@ public function verify()
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);
}
@@ -94,10 +114,6 @@ public function link()
} else {
$team = $user->teams()->first();
}
- if (is_null(data_get($user, 'email_verified_at'))) {
- $user->email_verified_at = now();
- $user->save();
- }
Auth::login($user);
session(['currentTeam' => $team]);
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 @@ public function callback(string $provider)
{
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 @@ public function callback(string $provider)
$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 895564caa..26a76ab3e 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -14,6 +14,27 @@ class UploadController extends BaseController
{
use AuthorizesRequests;
+ private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
+
+ private const ALLOWED_EXTENSIONS = [
+ 'sql',
+ 'sql.gz',
+ 'gz',
+ 'zip',
+ 'tar',
+ 'tar.gz',
+ 'tgz',
+ 'dump',
+ 'bak',
+ 'bson',
+ 'bson.gz',
+ 'archive',
+ 'archive.gz',
+ 'bz2',
+ 'xz',
+ 'dmp',
+ ];
+
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@@ -24,6 +45,21 @@ public function upload(Request $request)
$this->authorize('uploadBackup', $resource);
+ $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) {
@@ -46,29 +82,20 @@ public function upload(Request $request)
'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);
@@ -79,13 +106,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier)
]);
}
- 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 ffa71b55a..ee7f25431 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +13,8 @@
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -31,6 +34,16 @@ public function manual(Request $request)
$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,6 +58,8 @@ public function manual(Request $request)
$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%");
@@ -58,6 +73,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@@ -70,6 +91,12 @@ public function manual(Request $request)
$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([
'application' => $application->name,
'status' => 'failed',
@@ -81,6 +108,12 @@ public function manual(Request $request)
$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([
'application' => $application->name,
'status' => 'failed',
@@ -101,6 +134,17 @@ public function manual(Request $request)
}
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,
@@ -118,6 +162,15 @@ public function manual(Request $request)
'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',
@@ -134,6 +187,15 @@ public function manual(Request $request)
}
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/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index 62adf5410..64807d694 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -40,12 +43,15 @@ public function manual(Request $request)
$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');
}
@@ -68,6 +74,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@@ -78,6 +90,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('gitea', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -100,6 +118,17 @@ public function manual(Request $request)
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,
@@ -117,6 +146,15 @@ public function manual(Request $request)
'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.',
@@ -149,6 +187,15 @@ public function manual(Request $request)
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 4158016d0..b0e11f60c 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@@ -16,6 +17,8 @@
class Github extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -43,12 +46,14 @@ public function manual(Request $request)
$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');
@@ -82,6 +87,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@@ -92,6 +103,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@@ -114,6 +131,17 @@ public function manual(Request $request)
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,
@@ -131,6 +159,15 @@ public function manual(Request $request)
'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',
@@ -180,6 +217,7 @@ public function manual(Request $request)
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'),
@@ -224,6 +262,13 @@ public function normal(Request $request)
$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.');
}
}
@@ -246,12 +291,14 @@ public function normal(Request $request)
$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');
@@ -300,6 +347,17 @@ public function normal(Request $request)
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,
@@ -311,6 +369,17 @@ public function normal(Request $request)
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'],
@@ -360,6 +429,7 @@ public function normal(Request $request)
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'),
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 4453a0e7a..205bede8f 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +14,8 @@
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+
public function manual(Request $request)
{
try {
@@ -32,6 +35,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +64,7 @@ public function manual(Request $request)
$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 +73,9 @@ public function manual(Request $request)
$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',
@@ -101,6 +111,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
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([
'application' => $application->name,
'status' => 'failed',
@@ -110,6 +126,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@@ -132,6 +154,17 @@ public function manual(Request $request)
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,
@@ -150,6 +183,15 @@ public function manual(Request $request)
'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.',
@@ -182,6 +224,15 @@ public function manual(Request $request)
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\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ public function events(Request $request)
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 @@ public function events(Request $request)
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..a584bc111 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -2,7 +2,40 @@
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\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 +47,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 +64,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 +90,23 @@ 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,
+ '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 874e63825..8951ad50d 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
@@ -39,11 +40,22 @@ public function handle($request, $next, ...$abilities)
}
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/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php
new file mode 100644
index 000000000..a8f388c85
--- /dev/null
+++ b/app/Jobs/ApiTokenExpirationWarningJob.php
@@ -0,0 +1,49 @@
+whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->where('tokenable_type', User::class)
+ ->chunkById(100, function ($tokens) {
+ foreach ($tokens as $token) {
+ if (! $token->team_id) {
+ continue;
+ }
+ RateLimiter::attempt(
+ 'api-token-expiring:'.$token->id,
+ $maxAttempts = 0,
+ function () use ($token) {
+ Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
+ },
+ $decaySeconds = 7 * 24 * 3600,
+ );
+ }
+ });
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 7e5025c8a..4f9481794 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -33,6 +33,7 @@
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;
@@ -124,6 +129,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_nixpacks_args;
+ private $env_railpack_args;
+
private $docker_compose;
private $docker_compose_base64;
@@ -174,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;
@@ -414,6 +423,7 @@ private function detectBuildKitCapabilities(): void
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;
@@ -427,8 +437,11 @@ private function detectBuildKitCapabilities(): void
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'"],
@@ -461,6 +474,7 @@ private function detectBuildKitCapabilities(): void
}
} 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()}");
}
@@ -484,8 +498,12 @@ private function decide_what_to_do()
$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();
}
@@ -938,6 +956,37 @@ private function deploy_nixpacks_buildpack()
$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) {
@@ -1217,11 +1266,11 @@ private function generate_runtime_environment_variables()
$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) {
@@ -1592,6 +1641,7 @@ private function generate_buildtime_environment_variables()
// 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();
@@ -1644,6 +1694,7 @@ private function generate_buildtime_environment_variables()
}
} 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();
@@ -1983,7 +2034,11 @@ private function deploy_pull_request()
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();
@@ -2422,7 +2477,409 @@ private function generate_nixpacks_env_variables()
$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;
@@ -2538,7 +2995,7 @@ private function generate_env_variables()
// 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();
@@ -2550,7 +3007,7 @@ private function generate_env_variables()
}
} else {
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
@@ -3075,29 +3532,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -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 {$safeNetwork} -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 {$safeNetwork} -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 {$safeNetwork} -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 {$safeNetwork} -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 {$safeNetwork} -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);
@@ -3631,7 +4087,7 @@ private function add_build_env_variables_to_dockerfile()
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) {
@@ -3653,7 +4109,7 @@ private function add_build_env_variables_to_dockerfile()
} 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) {
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 041cd812c..54e386676 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -4,6 +4,7 @@
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 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,6 +33,7 @@ public function __construct(
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
+ public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
@@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
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'];
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index 7ce316dcd..98ad60fff 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
@@ -43,6 +44,9 @@ private function disableSshMux(): void
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) {
@@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
}
@@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
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;
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\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
+use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -35,7 +36,7 @@ public function handle(): void
$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 @@ public function handle(): void
}
$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 @@ public function handle(): void
$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 @@ public function handle(): void
// 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 @@ public function handle(): void
$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 @@ public function handle(): void
}
} 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/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 @@ public function handle()
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 @@ protected function cloneRemoteVolume()
);
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 @@ protected function cloneRemoteVolume()
} 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 @@ protected function cloneRemoteVolume()
try {
if ($this->targetServer) {
instant_remote_process([
- "rm -rf $targetCloneDir",
+ "rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
diff --git a/app/Livewire/ForcePasswordReset.php b/app/Livewire/ForcePasswordReset.php
index e6392497f..2463c68e4 100644
--- a/app/Livewire/ForcePasswordReset.php
+++ b/app/Livewire/ForcePasswordReset.php
@@ -47,14 +47,10 @@ public function submit()
try {
$this->rateLimit(10);
$this->validate();
- $firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
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/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 364163ff8..724dd0bac 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -45,7 +45,7 @@ class Email extends Component
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;
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index b8f5e891a..9ed0ab807 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -110,19 +110,6 @@ public function getLogLinesProperty()
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
{
$this->authorize('update', $this->application);
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 25ce82eb0..258b54eed 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -197,12 +197,12 @@ protected function messages(): array
'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.',
- 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ '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.',
@@ -606,7 +606,7 @@ public function updatedBuildPack()
// 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();
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index 8d5baf034..a200d55e3 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -85,8 +85,12 @@ protected function rules(): array
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' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -105,10 +109,8 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index f291251ae..1f29d4290 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -98,7 +98,9 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'dragonflyPassword' => 'required|string',
+ 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -118,8 +120,7 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index 5baa3b0f0..4bf2165fb 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -101,7 +101,9 @@ protected function rules(): array
'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' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -123,8 +125,7 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index 16699298f..95666cc24 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -76,10 +76,18 @@ 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' => ValidationPatterns::portMappingRules(),
@@ -99,10 +107,10 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index ab537dc31..c64802466 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -77,9 +77,15 @@ protected function rules(): array
'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' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -99,9 +105,9 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index e789f03e7..49f204186 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -78,10 +78,18 @@ 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' => ValidationPatterns::portMappingRules(),
@@ -102,10 +110,10 @@ protected function messages(): array
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.',
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index fde1cb415..38cb770e6 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -88,9 +88,15 @@ 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',
@@ -114,9 +120,9 @@ protected function messages(): array
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.',
@@ -361,9 +367,14 @@ public function save_init_script($script)
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);
@@ -407,9 +418,11 @@ public function delete_init_script($script)
$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}";
@@ -446,8 +459,8 @@ public function save_new_init_script()
]);
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());
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index c36106937..7d1979435 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -83,8 +83,12 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'redisUsername' => 'required',
- 'redisPassword' => 'required',
+ 'redisUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->redisUsername !== $this->database->redis_username,
+ ),
+ 'redisPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->redisPassword !== $this->database->redis_password,
+ ),
'enableSsl' => 'boolean',
];
}
@@ -102,8 +106,8 @@ protected function messages(): array
'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'),
]
);
}
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 86e407136..0ce1bd1a2 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -81,9 +81,11 @@ public function updatedSelectedRepositoryId(): void
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;
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index 5a6f288b3..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -94,9 +94,11 @@ public function mount()
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;
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index b350538ac..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -96,9 +96,11 @@ public function mount()
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;
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 1b574395b..877142d8b 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -63,13 +63,16 @@ public function mount()
$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
@@ -171,6 +174,12 @@ public function submit()
{
$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();
@@ -196,6 +205,11 @@ public function submit()
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/Storage.php b/app/Livewire/Project/Service/Storage.php
index 433c2b13c..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
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');
}
@@ -106,8 +110,12 @@ public function submitPersistentVolume()
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
- 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
- ], ValidationPatterns::volumeNameMessages());
+ '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;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index c51b27b6a..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',
@@ -85,7 +98,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -116,12 +129,12 @@ public function availableSharedVariables(): array
$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
}
}
@@ -131,7 +144,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -141,7 +154,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -149,7 +162,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -160,7 +173,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -168,7 +181,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -179,7 +192,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -192,6 +205,7 @@ public function availableSharedVariables(): array
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 a69a41d90..f5f0f32e6 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 @@ public function mount()
$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;
@@ -213,7 +215,7 @@ public function submit($data = null)
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();
@@ -225,7 +227,7 @@ private function updateOrder()
}
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();
@@ -240,7 +242,7 @@ private function updateOrder()
private function handleBulkSubmit()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -260,7 +262,7 @@ private function handleBulkSubmit()
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@@ -286,6 +288,7 @@ private function handleBulkSubmit()
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.');
@@ -353,6 +356,23 @@ private function deleteRemovedVariables($isPreview, $variables)
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 6af89ffbb..094320bdd 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;
@@ -66,23 +71,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();
@@ -110,9 +123,11 @@ public function refresh()
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',
@@ -242,7 +257,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -273,12 +288,12 @@ public function availableSharedVariables(): array
$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
}
}
@@ -288,7 +303,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -298,7 +313,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -306,7 +321,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -317,7 +332,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -325,7 +340,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -336,7 +351,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
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/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index 97d2bfb44..08503359e 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -13,10 +13,20 @@ 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;
public bool $canUseRootPermissions = false;
@@ -118,8 +128,10 @@ public function addNewToken()
$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();
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
session()->flash('token', $token->plainTextToken);
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index ef6d0ed56..c55b4ddb8 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 @@ protected function rules(): array
'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 @@ protected function messages(): array
'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 @@ public function syncData(bool $toModel = false)
$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 @@ public function syncData(bool $toModel = false)
$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;
@@ -412,7 +421,7 @@ public function checkHetznerServerStatus(bool $manual = false)
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;
@@ -477,7 +486,7 @@ public function startHetznerServer()
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/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 6f6815b1f..5be066c7f 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -40,6 +40,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
+ #[Validate('boolean')]
+ public bool $is_mcp_server_enabled;
+
public function rules()
{
return [
@@ -52,6 +55,7 @@ public function rules()
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
}
@@ -70,6 +74,7 @@ public function mount()
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
+ $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
}
public function submit()
@@ -155,6 +160,7 @@ public function instantSave()
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
+ $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index 5a7c74172..9cfc4f6c8 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -38,7 +38,7 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
- #[Validate('nullable|string|max:50')]
+ #[Validate(['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'])]
public ?string $dev_helper_version = null;
public array $domainConflicts = [];
@@ -52,6 +52,7 @@ class Index extends Component
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
+ 'dev_helper_version.regex' => 'Dev helper version must match Docker tag format (alphanumeric, _, ., -; first char cannot be . or -).',
];
public function render()
@@ -191,6 +192,8 @@ public function buildHelperImage()
return;
}
+ $this->validateOnly('dev_helper_version');
+
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
@@ -198,7 +201,14 @@ public function buildHelperImage()
return;
}
- $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
+ if (! preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/', (string) $version)) {
+ $this->dispatch('error', 'Invalid helper version format.');
+
+ return;
+ }
+
+ $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
+ $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
command: [$buildCommand],
diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php
index eda20342b..c3db34066 100644
--- a/app/Livewire/Storage/Create.php
+++ b/app/Livewire/Storage/Create.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@@ -37,7 +38,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
- 'endpoint' => 'required|url|max:255',
+ 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -55,7 +56,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
- 'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 310cf24d4..b83b41e9f 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -44,7 +45,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
- 'endpoint' => 'required|url|max:255',
+ 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -62,7 +63,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
- 'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php
new file mode 100644
index 000000000..10d87ae92
--- /dev/null
+++ b/app/Mcp/Concerns/BuildsResponse.php
@@ -0,0 +1,225 @@
+
+ */
+ protected array $sensitiveKeys = [
+ // raw IDs / morph types (uuid is the public identifier)
+ 'id', 'team_id', 'tokenable_id', 'tokenable_type',
+ 'server_id', 'private_key_id', 'cloud_provider_token_id',
+ 'hetzner_server_id', 'environment_id', 'destination_id',
+ 'source_id', 'repository_project_id', 'application_id',
+ 'service_id', 'project_id', 'parent_id',
+ 'resourceable', 'resourceable_id', 'resourceable_type',
+ 'destination_type', 'source_type', 'tokenable',
+
+ // sentinel / observability secrets
+ 'sentinel_token', 'sentinel_custom_url',
+ 'logdrain_newrelic_license_key', 'logdrain_axiom_api_key',
+ 'logdrain_custom_config', 'logdrain_custom_config_parser',
+
+ // database passwords
+ 'postgres_password', 'dragonfly_password', 'keydb_password',
+ 'redis_password', 'mongo_initdb_root_password',
+ 'mariadb_password', 'mariadb_root_password',
+ 'mysql_password', 'mysql_root_password',
+ 'clickhouse_admin_password',
+
+ // app/env secrets
+ 'value', 'real_value', 'http_basic_auth_password',
+
+ // database connection strings embed credentials
+ 'internal_db_url', 'external_db_url', 'init_scripts',
+
+ // webhook secrets
+ 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea',
+ 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab',
+
+ // bulky / unsafe blobs
+ 'dockerfile', 'docker_compose', 'docker_compose_raw',
+ 'custom_labels', 'environment_variables',
+ 'environment_variables_preview', 'validation_logs',
+ 'server_metadata',
+ ];
+
+ /**
+ * Recursively remove sensitive keys from any nested array structure.
+ *
+ * @param array $data
+ * @return array
+ */
+ protected function scrubSensitive(array $data): array
+ {
+ $deny = array_flip($this->sensitiveKeys);
+
+ $walk = function ($value) use (&$walk, $deny) {
+ if (! is_array($value)) {
+ return $value;
+ }
+
+ $out = [];
+ foreach ($value as $key => $inner) {
+ if (is_string($key) && isset($deny[$key])) {
+ continue;
+ }
+ $out[$key] = $walk($inner);
+ }
+
+ return $out;
+ };
+
+ return $walk($data);
+ }
+
+ /**
+ * @param array|array $data
+ * @param array> $actions
+ * @param array|null $pagination
+ */
+ protected function respond(array $data, array $actions = [], ?array $pagination = null): Response
+ {
+ $payload = ['data' => $data];
+
+ if ($actions !== []) {
+ $payload['_actions'] = $actions;
+ }
+
+ if ($pagination !== null) {
+ $payload['_pagination'] = $pagination;
+ }
+
+ return Response::json($payload);
+ }
+
+ /**
+ * @return array{page:int, per_page:int, offset:int}
+ */
+ protected function paginationArgs(Request $request): array
+ {
+ $page = max(1, (int) ($request->get('page') ?? 1));
+ $perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage);
+ $perPage = max(1, min($this->maxPerPage, $perPage));
+
+ return [
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'offset' => ($page - 1) * $perPage,
+ ];
+ }
+
+ /**
+ * @param array{page:int, per_page:int, offset:int} $args
+ * @return array|null
+ */
+ protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array
+ {
+ $page = $args['page'];
+ $perPage = $args['per_page'];
+ $totalPages = (int) ceil($total / $perPage);
+
+ $meta = [
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'total' => $total,
+ 'total_pages' => $totalPages,
+ ];
+
+ if ($page < $totalPages) {
+ $meta['next'] = [
+ 'tool' => $tool,
+ 'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]),
+ ];
+ }
+
+ return $meta;
+ }
+
+ /**
+ * HATEOAS-style action suggestions for an application.
+ *
+ * @return array>
+ */
+ protected function actionsForApplication(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForDatabase(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForService(string $uuid, ?string $status = null): array
+ {
+ $actions = [
+ ['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+
+ $s = strtolower((string) $status);
+ if (str_contains($s, 'running')) {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
+ } else {
+ $actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
+ }
+
+ return $actions;
+ }
+
+ /**
+ * @return array>
+ */
+ protected function actionsForServer(string $uuid): array
+ {
+ return [
+ ['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
+ ];
+ }
+}
diff --git a/app/Mcp/Concerns/ResolvesTeam.php b/app/Mcp/Concerns/ResolvesTeam.php
new file mode 100644
index 000000000..f75219fcf
--- /dev/null
+++ b/app/Mcp/Concerns/ResolvesTeam.php
@@ -0,0 +1,35 @@
+user();
+ if (! $user) {
+ return Response::error('Unauthenticated.');
+ }
+
+ $token = $user->currentAccessToken();
+ if (! $token) {
+ return Response::error('Invalid token.');
+ }
+
+ if ($token->can('root') || $token->can($ability)) {
+ return null;
+ }
+
+ return Response::error("Missing required permissions: {$ability}");
+ }
+
+ protected function resolveTeamId(Request $request): ?int
+ {
+ $token = $request->user()?->currentAccessToken();
+
+ return $token?->team_id;
+ }
+}
diff --git a/app/Mcp/Servers/CoolifyServer.php b/app/Mcp/Servers/CoolifyServer.php
new file mode 100644
index 000000000..aff7e3f76
--- /dev/null
+++ b/app/Mcp/Servers/CoolifyServer.php
@@ -0,0 +1,50 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
+ if (! $application) {
+ return Response::error("Application [{$uuid}] not found.");
+ }
+
+ // Drop relations that the server_status accessor lazy-loads — they
+ // pull in sensitive nested data (server.settings.sentinel_token, etc.)
+ $application->setRelations([]);
+ $application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
+
+ return $this->respond(
+ $this->scrubSensitive($application->toArray()),
+ $this->actionsForApplication($uuid, $application->status),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Application UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetDatabase.php b/app/Mcp/Tools/GetDatabase.php
new file mode 100644
index 000000000..4eee9c961
--- /dev/null
+++ b/app/Mcp/Tools/GetDatabase.php
@@ -0,0 +1,58 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
+ if (! $database) {
+ return Response::error("Database [{$uuid}] not found.");
+ }
+
+ // Drop relations so deep server/destination data doesn't leak.
+ $database->setRelations([]);
+ $database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
+
+ return $this->respond(
+ $this->scrubSensitive($database->toArray()),
+ $this->actionsForDatabase($uuid, $database->status ?? null),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Database UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetInfrastructureOverview.php b/app/Mcp/Tools/GetInfrastructureOverview.php
new file mode 100644
index 000000000..06e91ff57
--- /dev/null
+++ b/app/Mcp/Tools/GetInfrastructureOverview.php
@@ -0,0 +1,93 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $servers = Server::whereTeamId($teamId)
+ ->select('id', 'name', 'uuid', 'ip', 'description')
+ ->with('settings:id,server_id,is_reachable,is_usable')
+ ->get()
+ ->map(fn ($s) => [
+ 'uuid' => $s->uuid,
+ 'name' => $s->name,
+ 'ip' => $s->ip,
+ 'is_reachable' => $s->settings?->is_reachable,
+ 'is_usable' => $s->settings?->is_usable,
+ ])
+ ->values()
+ ->all();
+
+ $projects = Project::where('team_id', $teamId)->get();
+
+ $appCount = 0;
+ $serviceCount = 0;
+ $databaseCount = 0;
+ $projectSummaries = [];
+
+ foreach ($projects as $project) {
+ $apps = $project->applications()->count();
+ $services = $project->services()->count();
+ $databases = $project->databases()->count();
+
+ $appCount += $apps;
+ $serviceCount += $services;
+ $databaseCount += $databases;
+
+ $projectSummaries[] = [
+ 'uuid' => $project->uuid,
+ 'name' => $project->name,
+ 'counts' => [
+ 'applications' => $apps,
+ 'services' => $services,
+ 'databases' => $databases,
+ ],
+ ];
+ }
+
+ return $this->respond([
+ 'coolify_version' => config('constants.coolify.version'),
+ 'servers' => $servers,
+ 'projects' => $projectSummaries,
+ 'counts' => [
+ 'servers' => count($servers),
+ 'projects' => count($projectSummaries),
+ 'applications' => $appCount,
+ 'services' => $serviceCount,
+ 'databases' => $databaseCount,
+ ],
+ ]);
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [];
+ }
+}
diff --git a/app/Mcp/Tools/GetServer.php b/app/Mcp/Tools/GetServer.php
new file mode 100644
index 000000000..fc3e72f14
--- /dev/null
+++ b/app/Mcp/Tools/GetServer.php
@@ -0,0 +1,57 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first();
+ if (! $server) {
+ return Response::error("Server [{$uuid}] not found.");
+ }
+
+ $data = $this->scrubSensitive($server->toArray());
+ $data['is_reachable'] = $server->settings?->is_reachable;
+ $data['is_usable'] = $server->settings?->is_usable;
+ $data['connection_timeout'] = $server->settings?->connection_timeout;
+
+ return $this->respond($data, $this->actionsForServer($uuid));
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Server UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/GetService.php b/app/Mcp/Tools/GetService.php
new file mode 100644
index 000000000..475958272
--- /dev/null
+++ b/app/Mcp/Tools/GetService.php
@@ -0,0 +1,61 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $uuid = $request->get('uuid');
+ if (! is_string($uuid) || $uuid === '') {
+ return Response::error('uuid argument is required.');
+ }
+
+ $service = Service::whereRelation('environment.project.team', 'id', $teamId)
+ ->where('uuid', $uuid)
+ ->first();
+
+ if (! $service) {
+ return Response::error("Service [{$uuid}] not found.");
+ }
+
+ $service->setRelations([]);
+ $service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']);
+
+ return $this->respond(
+ $this->scrubSensitive($service->toArray()),
+ $this->actionsForService($uuid, $service->status ?? null),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'uuid' => $schema->string()->description('Service UUID.')->required(),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListApplications.php b/app/Mcp/Tools/ListApplications.php
new file mode 100644
index 000000000..815edd61a
--- /dev/null
+++ b/app/Mcp/Tools/ListApplications.php
@@ -0,0 +1,77 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $tagName = $request->get('tag');
+ if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) {
+ return Response::error('tag argument must be a non-empty string.');
+ }
+ $args = $this->paginationArgs($request);
+
+ $query = Application::ownedByCurrentTeamAPI($teamId)
+ ->when($tagName !== null, function ($query) use ($tagName) {
+ $query->whereHas('tags', fn ($q) => $q->where('name', $tagName));
+ });
+
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($app) => [
+ 'uuid' => $app->uuid,
+ 'name' => $app->name,
+ 'status' => $app->status,
+ 'fqdn' => $app->fqdn,
+ 'git_repository' => $app->git_repository,
+ ])
+ ->values()
+ ->all();
+
+ $extra = $tagName ? ['tag' => $tagName] : [];
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_applications', $args, $total, $extra),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'tag' => $schema->string()->description('Optional tag name filter.'),
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListDatabases.php b/app/Mcp/Tools/ListDatabases.php
new file mode 100644
index 000000000..7eb1fde00
--- /dev/null
+++ b/app/Mcp/Tools/ListDatabases.php
@@ -0,0 +1,69 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $projects = Project::where('team_id', $teamId)->get();
+ $databases = collect();
+ foreach ($projects as $project) {
+ $databases = $databases->merge($project->databases());
+ }
+
+ $total = $databases->count();
+
+ $summaries = $databases
+ ->sortBy('name')
+ ->slice($args['offset'], $args['per_page'])
+ ->map(fn ($db) => [
+ 'uuid' => $db->uuid,
+ 'name' => $db->name,
+ 'status' => $db->status ?? null,
+ 'type' => method_exists($db, 'type') ? $db->type() : class_basename($db),
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_databases', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListProjects.php b/app/Mcp/Tools/ListProjects.php
new file mode 100644
index 000000000..9ce1576b9
--- /dev/null
+++ b/app/Mcp/Tools/ListProjects.php
@@ -0,0 +1,66 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Project::whereTeamId($teamId);
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->select('name', 'description', 'uuid')
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($p) => [
+ 'uuid' => $p->uuid,
+ 'name' => $p->name,
+ 'description' => $p->description,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_projects', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListServers.php b/app/Mcp/Tools/ListServers.php
new file mode 100644
index 000000000..20250c454
--- /dev/null
+++ b/app/Mcp/Tools/ListServers.php
@@ -0,0 +1,67 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable');
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($s) => [
+ 'uuid' => $s->uuid,
+ 'name' => $s->name,
+ 'ip' => $s->ip,
+ 'is_reachable' => $s->settings?->is_reachable,
+ 'is_usable' => $s->settings?->is_usable,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_servers', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Mcp/Tools/ListServices.php b/app/Mcp/Tools/ListServices.php
new file mode 100644
index 000000000..b0bff4fad
--- /dev/null
+++ b/app/Mcp/Tools/ListServices.php
@@ -0,0 +1,66 @@
+ensureAbility($request, 'read')) {
+ return $error;
+ }
+
+ $teamId = $this->resolveTeamId($request);
+ if (is_null($teamId)) {
+ return Response::error('Invalid token.');
+ }
+
+ $args = $this->paginationArgs($request);
+
+ $query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId));
+
+ $total = (clone $query)->count();
+
+ $summaries = $query
+ ->orderBy('name')
+ ->skip($args['offset'])
+ ->take($args['per_page'])
+ ->get()
+ ->map(fn ($svc) => [
+ 'uuid' => $svc->uuid,
+ 'name' => $svc->name,
+ 'status' => $svc->status ?? null,
+ ])
+ ->values()
+ ->all();
+
+ return $this->respond(
+ $summaries,
+ [],
+ $this->paginationMeta('list_services', $args, $total),
+ );
+ }
+
+ public function schema(JsonSchema $schema): array
+ {
+ return [
+ 'page' => $schema->integer()->description('Page number (default 1).'),
+ 'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
+ ];
+ }
+}
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 85e94bfd6..2a364e13f 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -39,7 +39,7 @@
'git_full_url' => ['type' => 'string', 'nullable' => true, 'description' => 'Git full URL.'],
'docker_registry_image_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image name.'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true, 'description' => 'Docker registry image tag.'],
- 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose']],
+ 'build_pack' => ['type' => 'string', 'description' => 'Build pack.', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose']],
'static_image' => ['type' => 'string', 'description' => 'Static image used when static site is deployed.'],
'install_command' => ['type' => 'string', 'description' => 'Install command.'],
'build_command' => ['type' => 'string', 'description' => 'Build command.'],
@@ -960,7 +960,7 @@ public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@@ -970,6 +970,13 @@ public function nixpacks_environment_variables()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', false)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -988,7 +995,7 @@ public function runtime_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@@ -998,6 +1005,13 @@ public function nixpacks_environment_variables_preview()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables_preview()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', true)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
@@ -1117,7 +1131,7 @@ public function deploymentType()
public function could_set_build_commands(): bool
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index f08a48cea..9159fd0d8 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -42,11 +43,18 @@ protected static function booted()
$networkKeys = collect($networks)->keys();
$volumeKeys = collect($volumes)->keys();
$volumeKeys->each(function ($key) use ($server) {
- instant_remote_process(["docker volume rm -f $key"], $server, false);
+ if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) {
+ return;
+ }
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false);
});
$networkKeys->each(function ($key) use ($server) {
- instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
- instant_remote_process(["docker network rm $key"], $server, false);
+ if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) {
+ return;
+ }
+ $k = escapeshellarg($key);
+ instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false);
+ instant_remote_process(["docker network rm {$k}"], $server, false);
});
} else {
// Regular application volume cleanup
diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php
index 83212267c..bfb02a470 100644
--- a/app/Models/EnvironmentVariable.php
+++ b/app/Models/EnvironmentVariable.php
@@ -3,6 +3,8 @@
namespace App\Models;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
+use App\Support\ValidationPatterns;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use OpenApi\Attributes as OA;
@@ -32,6 +34,8 @@
)]
class EnvironmentVariable extends BaseModel
{
+ public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
+
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
@@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel
'resourceable_id' => 'integer',
];
- protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
+ protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
protected static function booted()
{
- static::created(function (EnvironmentVariable $environment_variable) {
+ static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@@ -109,7 +113,7 @@ protected static function booted()
]);
});
- static::saving(function (EnvironmentVariable $environmentVariable) {
+ static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@@ -119,6 +123,30 @@ public function service()
return $this->belongsTo(Service::class);
}
+ public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
+ {
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ $query->where('key', 'not like', "{$prefix}%");
+ }
+
+ return $query;
+ }
+
+ public static function isBuildpackControlKey(?string $key): bool
+ {
+ if (blank($key)) {
+ return false;
+ }
+
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ if (str($key)->startsWith($prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
protected function value(): Attribute
{
return Attribute::make(
@@ -188,16 +216,10 @@ protected function isReallyRequired(): Attribute
);
}
- protected function isNixpacks(): Attribute
+ protected function isBuildpackControl(): Attribute
{
return Attribute::make(
- get: function () {
- if (str($this->key)->startsWith('NIXPACKS_')) {
- return true;
- }
-
- return false;
- }
+ get: fn () => self::isBuildpackControlKey($this->key),
);
}
@@ -349,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null)
protected function key(): Attribute
{
return Attribute::make(
- set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
+ set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
+ ValidationPatterns::normalizeEnvironmentVariableKey($value)
+ ),
);
}
diff --git a/app/Models/InstanceSettings.php b/app/Models/InstanceSettings.php
index 6061bc863..d5c3bfa28 100644
--- a/app/Models/InstanceSettings.php
+++ b/app/Models/InstanceSettings.php
@@ -45,6 +45,7 @@ class InstanceSettings extends Model
'is_sponsorship_popup_enabled',
'dev_helper_version',
'is_wire_navigate_enabled',
+ 'is_mcp_server_enabled',
];
protected $casts = [
@@ -67,6 +68,7 @@ class InstanceSettings extends Model
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
protected static function booted(): void
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 4b5c602c2..627750232 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel
{
+ public const MAX_CONTENT_SIZE = 5_242_880;
+
+ public const BINARY_PLACEHOLDER = '[binary file]';
+
+ public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
+
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
- public $appends = ['is_binary'];
+ public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
- get: function () {
- return $this->content === '[binary file]';
- }
+ get: fn () => $this->content === self::BINARY_PLACEHOLDER
+ );
+ }
+
+ protected function isTooLarge(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ $this->is_directory = false;
+ $this->save();
+
+ return;
+ }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
- $content = '[binary file]';
+ $content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
+ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
+ {
+ $sizeOutput = instant_remote_process(
+ ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
+ $server,
+ false,
+ );
+ $size = (int) trim((string) $sizeOutput);
+
+ return $size > self::MAX_CONTENT_SIZE;
+ }
+
public function deleteStorageOnServer()
{
$this->load(['service']);
@@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
- $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ } else {
+ $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ }
$this->is_directory = false;
- $this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index b28fcc744..bcbbe526e 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -2,11 +2,13 @@
namespace App\Models;
+use App\Rules\SafeWebhookUrl;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
@@ -139,6 +141,14 @@ protected function region(): Attribute
public function testConnection(bool $shouldSave = false)
{
try {
+ $validator = Validator::make(
+ ['endpoint' => $this['endpoint']],
+ ['endpoint' => ['required', new SafeWebhookUrl]],
+ );
+ if ($validator->fails()) {
+ throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
+ }
+
$disk = Storage::build([
'driver' => 's3',
'region' => $this['region'],
diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php
index 40f8e1860..0a53395d3 100644
--- a/app/Models/ScheduledTask.php
+++ b/app/Models/ScheduledTask.php
@@ -76,20 +76,14 @@ public function executions(): HasMany
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
- public function server()
+ public function server(): ?Server
{
if ($this->application) {
- if ($this->application->destination && $this->application->destination->server) {
- return $this->application->destination->server;
- }
- } elseif ($this->service) {
- if ($this->service->destination && $this->service->destination->server) {
- return $this->service->destination->server;
- }
- } elseif ($this->database) {
- if ($this->database->destination && $this->database->destination->server) {
- return $this->database->destination->server;
- }
+ return $this->application->destination?->server;
+ }
+
+ if ($this->service) {
+ return $this->service->destination?->server;
}
return null;
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 06426f211..74e8ba5b0 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
- if ($isReachable === true) {
- $this->unreachable_count = 0;
- $this->save();
+ if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
- $this->increment('unreachable_count');
-
- if ($this->unreachable_count === 1) {
- $this->settings->is_reachable = true;
- $this->settings->save();
-
- return;
- }
-
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
- $failedChecks = 0;
- for ($i = 0; $i < 3; $i++) {
- $status = $this->serverStatus();
- sleep(5);
- if (! $status) {
- $failedChecks++;
- }
- }
-
- if ($failedChecks === 3 && ! $unreachableNotificationSent) {
- $this->sendUnreachableNotification();
- }
+ $this->sendUnreachableNotification();
}
}
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 30fc1e165..79f62f4b7 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -49,6 +50,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@@ -97,6 +99,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
+ 'connection_timeout',
];
protected $casts = [
@@ -108,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
+ 'connection_timeout' => 'integer',
];
protected static function booted()
@@ -141,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
- public static function isValidSentinelToken(string $token): bool
+ public static function isValidSentinelToken(?string $token): bool
{
+ if ($token === null) {
+ return false;
+ }
+
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
- public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
+ /**
+ * Returns a valid sentinel token, regenerating it if the stored value is
+ * empty, undecryptable, or otherwise invalid. Throws only when regeneration
+ * still fails to produce a valid token.
+ */
+ public function ensureValidSentinelToken(): string
+ {
+ try {
+ $token = $this->sentinel_token;
+ } catch (DecryptException) {
+ $token = null;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ // Clear undecryptable raw value so Eloquent's dirty-check won't try to
+ // decrypt the bad original during save().
+ $attrs = $this->getAttributes();
+ $attrs['sentinel_token'] = null;
+ $this->setRawAttributes($attrs, true);
+
+ $this->generateSentinelToken(save: true, ignoreEvent: true);
+ $this->refresh();
+ $token = $this->sentinel_token;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
+ }
+
+ return $token;
+ }
+
+ public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
- $token = json_encode($data);
- $encrypted = encrypt($token);
- $this->sentinel_token = $encrypted;
+ $token = encrypt(json_encode($data));
+ $this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
diff --git a/app/Models/SharedEnvironmentVariable.php b/app/Models/SharedEnvironmentVariable.php
index fa6fd45e0..eadc33ec2 100644
--- a/app/Models/SharedEnvironmentVariable.php
+++ b/app/Models/SharedEnvironmentVariable.php
@@ -2,6 +2,8 @@
namespace App\Models;
+use App\Support\ValidationPatterns;
+use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
class SharedEnvironmentVariable extends Model
@@ -33,6 +35,13 @@ class SharedEnvironmentVariable extends Model
'value' => 'encrypted',
];
+ protected function key(): Attribute
+ {
+ return Attribute::make(
+ set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey($value),
+ );
+ }
+
public function team()
{
return $this->belongsTo(Team::class);
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index d6b4d1a1c..d12a15a7c 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -134,8 +134,11 @@ public function databases()
$mongodbs = $this->mongodbs;
$mysqls = $this->mysqls;
$mariadbs = $this->mariadbs;
+ $keydbs = $this->keydbs;
+ $dragonflies = $this->dragonflies;
+ $clickhouses = $this->clickhouses;
- return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
+ return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
}
public function attachedTo()
diff --git a/app/Models/User.php b/app/Models/User.php
index 02cf3abce..01b8902b7 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -257,7 +257,7 @@ public function sendVerificationEmail()
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $this->getKey(),
- 'hash' => sha1($this->getEmailForVerification()),
+ 'hash' => hash('sha256', $this->getEmailForVerification()),
]
);
$mail->view('emails.email-verification', [
diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php
new file mode 100644
index 000000000..451dd312a
--- /dev/null
+++ b/app/Notifications/ApiTokenExpiringNotification.php
@@ -0,0 +1,103 @@
+onQueue('high');
+ $this->tokenName = $token->name;
+ $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
+ $this->manageUrl = route('security.api-tokens');
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('api_token_expiring');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours");
+ $mail->view('emails.api-token-expiring', [
+ 'tokenName' => $this->tokenName,
+ 'expiresAt' => $this->expiresAt,
+ 'manageUrl' => $this->manageUrl,
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ $message = new DiscordMessage(
+ title: '🔑 API token expiring soon',
+ description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.",
+ color: DiscordMessage::warningColor(),
+ );
+
+ $message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})");
+
+ return $message;
+ }
+
+ public function toTelegram(): array
+ {
+ $message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages.";
+
+ return [
+ 'message' => $message,
+ 'buttons' => [
+ [
+ 'text' => 'Manage API tokens',
+ 'url' => $this->manageUrl,
+ ],
+ ],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $message = "API token {$this->tokenName} expires on {$this->expiresAt}.
";
+ $message .= 'Action Required: Rotate this token before it expires to avoid API outages.';
+
+ return new PushoverMessage(
+ title: 'API token expiring soon',
+ level: 'warning',
+ message: $message,
+ buttons: [
+ [
+ 'text' => 'Manage API tokens',
+ 'url' => $this->manageUrl,
+ ],
+ ],
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ $description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
+ $description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
+ $description .= "Manage tokens: {$this->manageUrl}";
+
+ return new SlackMessage(
+ title: '🔑 API token expiring soon',
+ description: $description,
+ color: SlackMessage::warningColor(),
+ );
+ }
+}
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
index 0caa3a3a9..c29f7fc41 100644
--- a/app/Providers/HorizonServiceProvider.php
+++ b/app/Providers/HorizonServiceProvider.php
@@ -3,9 +3,12 @@
namespace App\Providers;
use App\Contracts\CustomJobRepositoryInterface;
+use App\Exceptions\DeploymentException;
use App\Models\ApplicationDeploymentQueue;
use App\Models\User;
use App\Repositories\CustomJobRepository;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Contracts\JobRepository;
@@ -48,6 +51,26 @@ public function boot(): void
]);
}
});
+
+ Event::listen(function (JobFailed $event) {
+ if (! isCloud()) {
+ return;
+ }
+
+ $exception = $event->exception;
+ if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) {
+ return;
+ }
+
+ try {
+ $uuid = $event->job->uuid();
+ if ($uuid) {
+ app(JobRepository::class)->deleteFailed($uuid);
+ }
+ } catch (\Throwable $e) {
+ // Best-effort scrub; never mask the original failure.
+ }
+ });
}
protected function gate(): void
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
index fbeb406af..3723e1db5 100644
--- a/app/Rules/SafeWebhookUrl.php
+++ b/app/Rules/SafeWebhookUrl.php
@@ -40,9 +40,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$host = strtolower($host);
+ // Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
+ // literals can't sneak past filter_var FILTER_VALIDATE_IP.
+ $hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
+ ? substr($host, 1, -1)
+ : $host;
+
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
- if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) {
+ if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
@@ -55,7 +61,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
- if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
+ if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 88121384f..07926e1cf 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -36,15 +36,31 @@ class ValidationPatterns
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
- * Pattern for shell-safe command strings (docker compose commands, docker run options)
- * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns
- * Allows & for command chaining (&&) which is common in multi-step build commands
- * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
- * Blocks backslashes to prevent escape-sequence attacks
- * Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'")
- * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
+ * Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
+ *
+ * Accepts a sequence of the following tokens only:
+ * [ \t]+ — whitespace (space / tab)
+ * && — logical AND (matched before bare & can match anything)
+ * || — logical OR (matched before bare | can match anything)
+ * "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside
+ * '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal)
+ * [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !)
+ *
+ * Blocked everywhere (outside and inside unquoted tokens):
+ * bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR
+ *
+ * Blocked inside double-quoted spans specifically:
+ * $ (variable/command expansion), ` (command substitution), \ (escape)
+ *
+ * Legitimate use cases preserved:
+ * docker compose build && docker tag x && docker push y
+ * make build || make clean
+ * rm *.tmp cp src/?.js dist/
+ * ! grep -q foo && echo missing
+ * docker compose up -d --build-arg VERSION="1.0.0"
+ * --entrypoint "sh -c 'npm start'"
*/
- public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/';
+ public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/';
/**
* Pattern for Docker volume names
@@ -66,6 +82,179 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+ /**
+ * Pattern for Docker-compatible environment variable keys.
+ * Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
+ */
+ public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
+
+ /**
+ * Pattern for SQL-safe unquoted database identifiers (usernames, database names).
+ * Allows letters, digits, underscore; first char must be letter or underscore.
+ * Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit).
+ */
+ public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/';
+
+ /**
+ * Pattern for database passwords.
+ * Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null.
+ * Allows a broad set of printable characters so passwords remain strong.
+ */
+ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
+
+ /**
+ * Normalize environment variable keys before validation and storage.
+ */
+ public static function normalizeEnvironmentVariableKey(string $value): string
+ {
+ return str($value)->trim()->value;
+ }
+
+ /**
+ * Get validation rules for environment variable keys.
+ */
+ public static function environmentVariableKeyRules(bool $required = true, int $maxLength = 255): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::ENVIRONMENT_VARIABLE_KEY_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for environment variable key fields.
+ */
+ public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
+ {
+ return [
+ "{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Check if a string is a valid environment variable key.
+ */
+ public static function isValidEnvironmentVariableKey(string $value): bool
+ {
+ return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
+ }
+
+ /**
+ * Normalize and validate an environment variable key.
+ */
+ public static function validatedEnvironmentVariableKey(string $value, string $label = 'key'): string
+ {
+ $key = self::normalizeEnvironmentVariableKey($value);
+
+ if (! self::isValidEnvironmentVariableKey($key)) {
+ throw new \InvalidArgumentException(self::environmentVariableKeyMessages(label: $label)['key.regex']);
+ }
+
+ return $key;
+ }
+
+ /**
+ * Get validation rules for database identifier fields (username, database name).
+ *
+ * Set $enforcePattern to false to skip the regex check (for example when
+ * re-validating a legacy value on an existing record that has not been
+ * changed by the user). The length and type rules are always applied.
+ */
+ public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "min:$minLength";
+ $rules[] = "max:$maxLength";
+
+ if ($enforcePattern) {
+ $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for database identifier fields.
+ */
+ public static function databaseIdentifierMessages(string $field, string $label = ''): array
+ {
+ $label = $label ?: $field;
+
+ return [
+ "{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.",
+ "{$field}.min" => "The {$label} must be at least :min character.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Get validation rules for database password fields.
+ *
+ * Set $enforcePattern to false to skip the regex check (for example when
+ * re-validating a legacy value on an existing record that has not been
+ * changed by the user). The length and type rules are always applied.
+ */
+ public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "min:$minLength";
+ $rules[] = "max:$maxLength";
+
+ if ($enforcePattern) {
+ $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for database password fields.
+ */
+ public static function databasePasswordMessages(string $field, string $label = ''): array
+ {
+ $label = $label ?: $field;
+
+ return [
+ "{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).",
+ "{$field}.min" => "The {$label} must be at least :min character.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Check if a string is a valid database identifier.
+ */
+ public static function isValidDatabaseIdentifier(string $value): bool
+ {
+ return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1;
+ }
+
/**
* Get validation rules for name fields
*/
diff --git a/app/Traits/HasMetrics.php b/app/Traits/HasMetrics.php
index 7ed82cc91..20b3752f5 100644
--- a/app/Traits/HasMetrics.php
+++ b/app/Traits/HasMetrics.php
@@ -2,7 +2,9 @@
namespace App\Traits;
-use App\Models\ServerSetting;
+use App\Models\Server;
+use Illuminate\Contracts\Encryption\DecryptException;
+use Illuminate\Support\Facades\Log;
trait HasMetrics
{
@@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
- $token = $server->settings->sentinel_token;
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
+ $previousToken = null;
+ try {
+ $previousToken = $server->settings->sentinel_token;
+ } catch (DecryptException) {
+ // fall through to ensureValidSentinelToken which will regenerate
+ }
+ $token = $server->settings->ensureValidSentinelToken();
+ if ($token !== $previousToken) {
+ Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
}
$response = instant_remote_process(
@@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool
{
- return $this instanceof \App\Models\Server;
+ return $this instanceof Server;
}
- private function getMetricsServer(): \App\Models\Server
+ private function getMetricsServer(): Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php
index fded435fd..9333eb504 100644
--- a/app/Traits/HasNotificationSettings.php
+++ b/app/Traits/HasNotificationSettings.php
@@ -19,6 +19,7 @@ trait HasNotificationSettings
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
+ 'api_token_expiring',
];
/**
diff --git a/bootstrap/helpers/applications.php b/bootstrap/helpers/applications.php
index 48e0a8c78..4707b0a07 100644
--- a/bootstrap/helpers/applications.php
+++ b/bootstrap/helpers/applications.php
@@ -12,8 +12,9 @@
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
-function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
+function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
+ $commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
$deployment_url = $deployment_link->getPath();
diff --git a/bootstrap/helpers/audit.php b/bootstrap/helpers/audit.php
new file mode 100644
index 000000000..8477450c4
--- /dev/null
+++ b/bootstrap/helpers/audit.php
@@ -0,0 +1,81 @@
+ $context Identifiers + outcome details.
+ * @param string $level Log level: info | warning | error.
+ */
+ function auditLog(string $event, array $context = [], string $level = 'info'): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+ $user = auth()->check() ? auth()->user() : null;
+ $token = $user?->currentAccessToken();
+
+ $base = [
+ 'event' => $event,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'user_id' => $user?->id,
+ 'user_email' => $user?->email,
+ 'team_id' => $token ? data_get($token, 'team_id') : null,
+ 'token_id' => $token?->id ?? null,
+ 'token_name' => $token?->name ?? null,
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ ];
+
+ $payload = array_merge($base, $context);
+
+ Log::channel('audit')->{$level}($event, $payload);
+ } catch (Throwable $e) {
+ // Audit logging must never break the request path.
+ try {
+ Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
+
+if (! function_exists('auditLogWebhookFailure')) {
+ /**
+ * Record a webhook signature/auth verification failure to the `audit` channel.
+ */
+ function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
+ {
+ try {
+ $request = app()->bound('request') ? request() : null;
+
+ $event = "webhook.{$provider}.signature_failed";
+
+ $base = [
+ 'event' => $event,
+ 'reason' => $reason,
+ 'ip' => $request?->ip(),
+ 'ua' => substr((string) $request?->userAgent(), 0, 200),
+ 'method' => $request?->method(),
+ 'path' => $request?->path(),
+ 'event_header' => $request?->header('X-GitHub-Event')
+ ?? $request?->header('X-Gitlab-Event')
+ ?? $request?->header('X-Gitea-Event')
+ ?? $request?->header('X-Event-Key'),
+ ];
+
+ Log::channel('audit')->warning($event, array_merge($base, $context));
+ } catch (Throwable $e) {
+ try {
+ Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
+ } catch (Throwable) {
+ }
+ }
+ }
+}
diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php
index bae2573de..043a3346d 100644
--- a/bootstrap/helpers/constants.php
+++ b/bootstrap/helpers/constants.php
@@ -1,7 +1,26 @@
';
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
+const STANDALONE_DATABASE_MODELS = [
+ 'postgresql' => StandalonePostgresql::class,
+ 'redis' => StandaloneRedis::class,
+ 'mongodb' => StandaloneMongodb::class,
+ 'mysql' => StandaloneMysql::class,
+ 'mariadb' => StandaloneMariadb::class,
+ 'keydb' => StandaloneKeydb::class,
+ 'dragonfly' => StandaloneDragonfly::class,
+ 'clickhouse' => StandaloneClickhouse::class,
+];
const VALID_CRON_STRINGS = [
'every_minute' => '* * * * *',
'hourly' => '0 * * * *',
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 24628a482..4e59bb39a 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -157,6 +157,73 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
+/**
+ * Validate that a filename is safe for use as a plain file name (no path components).
+ *
+ * Prevents path traversal attacks by rejecting directory separators, traversal
+ * sequences, and null bytes, in addition to all shell metacharacters blocked by
+ * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
+ * init script names that are later written to a specific directory on the host.
+ *
+ * @param string $input The filename to validate
+ * @param string $context Descriptive name for error messages (e.g., 'init script filename')
+ * @return string The validated input (unchanged if valid)
+ *
+ * @throws Exception If dangerous characters or path traversal sequences are detected
+ */
+function validateFilenameSafe(string $input, string $context = 'filename'): string
+{
+ // First apply shell-metachar checks
+ validateShellSafePath($input, $context);
+
+ // Reject NUL bytes (can be used to truncate path strings in some contexts)
+ if (str_contains($input, "\0")) {
+ throw new Exception(
+ "Invalid {$context}: contains null byte. ".
+ 'Null bytes are not allowed in filenames for security reasons.'
+ );
+ }
+
+ // Reject directory separators — filename must be a single path component
+ if (str_contains($input, '/') || str_contains($input, '\\')) {
+ throw new Exception(
+ "Invalid {$context}: directory separators ('/' or '\\') are not allowed. ".
+ 'Provide a plain filename without path components.'
+ );
+ }
+
+ // Reject path traversal sequences (catches encoded or unusual forms)
+ if (str_contains($input, '..')) {
+ throw new Exception(
+ "Invalid {$context}: path traversal sequence ('..') is not allowed."
+ );
+ }
+
+ // Reject shell globbing / expansion metacharacters and whitespace that would
+ // split the filename into additional shell arguments if ever interpolated
+ // unquoted (defence in depth on top of escapeshellarg() at call sites).
+ $shellExpansionChars = [
+ ' ' => 'whitespace',
+ '*' => 'glob wildcard',
+ '?' => 'glob wildcard',
+ '[' => 'glob character class',
+ ']' => 'glob character class',
+ '~' => 'tilde expansion',
+ '"' => 'double quote',
+ "'" => 'single quote',
+ ];
+
+ foreach ($shellExpansionChars as $char => $description) {
+ if (str_contains($input, $char)) {
+ throw new Exception(
+ "Invalid {$context}: contains forbidden character '{$char}' ({$description})."
+ );
+ }
+ }
+
+ return $input;
+}
+
/**
* Validate that a databases_to_backup input string is safe from command injection.
*
@@ -999,44 +1066,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
{
- $postgresql = StandalonePostgresql::whereUuid($uuid)->first();
- if ($postgresql && $postgresql->team()->id == $teamId) {
- return $postgresql->unsetRelation('environment');
- }
- $redis = StandaloneRedis::whereUuid($uuid)->first();
- if ($redis && $redis->team()->id == $teamId) {
- return $redis->unsetRelation('environment');
- }
- $mongodb = StandaloneMongodb::whereUuid($uuid)->first();
- if ($mongodb && $mongodb->team()->id == $teamId) {
- return $mongodb->unsetRelation('environment');
- }
- $mysql = StandaloneMysql::whereUuid($uuid)->first();
- if ($mysql && $mysql->team()->id == $teamId) {
- return $mysql->unsetRelation('environment');
- }
- $mariadb = StandaloneMariadb::whereUuid($uuid)->first();
- if ($mariadb && $mariadb->team()->id == $teamId) {
- return $mariadb->unsetRelation('environment');
- }
- $keydb = StandaloneKeydb::whereUuid($uuid)->first();
- if ($keydb && $keydb->team()->id == $teamId) {
- return $keydb->unsetRelation('environment');
- }
- $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
- if ($dragonfly && $dragonfly->team()->id == $teamId) {
- return $dragonfly->unsetRelation('environment');
- }
- $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
- if ($clickhouse && $clickhouse->team()->id == $teamId) {
- return $clickhouse->unsetRelation('environment');
+ foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
+ $database = $modelClass::whereUuid($uuid)->first();
+ if ($database && $database->team()->id == $teamId) {
+ return $database->unsetRelation('environment');
+ }
}
return null;
}
function queryResourcesByUuid(string $uuid)
{
- $resource = null;
$application = Application::whereUuid($uuid)->first();
if ($application) {
return $application;
@@ -1045,37 +1085,11 @@ function queryResourcesByUuid(string $uuid)
if ($service) {
return $service;
}
- $postgresql = StandalonePostgresql::whereUuid($uuid)->first();
- if ($postgresql) {
- return $postgresql;
- }
- $redis = StandaloneRedis::whereUuid($uuid)->first();
- if ($redis) {
- return $redis;
- }
- $mongodb = StandaloneMongodb::whereUuid($uuid)->first();
- if ($mongodb) {
- return $mongodb;
- }
- $mysql = StandaloneMysql::whereUuid($uuid)->first();
- if ($mysql) {
- return $mysql;
- }
- $mariadb = StandaloneMariadb::whereUuid($uuid)->first();
- if ($mariadb) {
- return $mariadb;
- }
- $keydb = StandaloneKeydb::whereUuid($uuid)->first();
- if ($keydb) {
- return $keydb;
- }
- $dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
- if ($dragonfly) {
- return $dragonfly;
- }
- $clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
- if ($clickhouse) {
- return $clickhouse;
+ foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
+ $database = $modelClass::whereUuid($uuid)->first();
+ if ($database) {
+ return $database;
+ }
}
// Check for ServiceDatabase by its own UUID
@@ -1084,7 +1098,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
- return $resource;
+ return null;
}
function generateTagDeployWebhook($tag_name)
{
@@ -1394,23 +1408,23 @@ function generateEnvValue(string $command, Service|Application|null $service = n
break;
// This is base64,
case 'REALBASE64_64':
- $generatedValue = base64_encode(Str::random(64));
+ $generatedValue = base64_encode(random_bytes(64));
break;
case 'REALBASE64_128':
- $generatedValue = base64_encode(Str::random(128));
+ $generatedValue = base64_encode(random_bytes(128));
break;
case 'REALBASE64':
case 'REALBASE64_32':
- $generatedValue = base64_encode(Str::random(32));
+ $generatedValue = base64_encode(random_bytes(32));
break;
case 'HEX_32':
- $generatedValue = bin2hex(Str::random(32));
+ $generatedValue = bin2hex(random_bytes(16));
break;
case 'HEX_64':
- $generatedValue = bin2hex(Str::random(64));
+ $generatedValue = bin2hex(random_bytes(32));
break;
case 'HEX_128':
- $generatedValue = bin2hex(Str::random(128));
+ $generatedValue = bin2hex(random_bytes(64));
break;
case 'USER':
$generatedValue = Str::random(16);
@@ -3473,10 +3487,10 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
- // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
- return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
+ // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
+ return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
- return 'wire:navigate.hover';
+ return 'wire:navigate';
}
}
diff --git a/composer.json b/composer.json
index e2b16b31b..9415aa624 100644
--- a/composer.json
+++ b/composer.json
@@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
+ "laravel/mcp": "^0.6.7",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",
diff --git a/composer.lock b/composer.lock
index 2f27235f5..36715d2c5 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "40bddea995c1744e4aec517263109a2f",
+ "content-hash": "64b77285a7140ce68e83db2659e9a21d",
"packages": [
{
"name": "aws/aws-crt-php",
@@ -2066,6 +2066,79 @@
},
"time": "2026-03-18T14:14:59+00:00"
},
+ {
+ "name": "laravel/mcp",
+ "version": "v0.6.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
+ "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/container": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/http": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/json-schema": "^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
+ "php": "^8.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "pestphp/pest": "^3.8.5|^4.3.2",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2026-04-15T08:30:42+00:00"
+ },
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
@@ -13738,79 +13811,6 @@
},
"time": "2026-03-21T11:50:49+00:00"
},
- {
- "name": "laravel/mcp",
- "version": "v0.6.4",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/mcp.git",
- "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
- "reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
- "shasum": ""
- },
- "require": {
- "ext-json": "*",
- "ext-mbstring": "*",
- "illuminate/console": "^11.45.3|^12.41.1|^13.0",
- "illuminate/container": "^11.45.3|^12.41.1|^13.0",
- "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
- "illuminate/http": "^11.45.3|^12.41.1|^13.0",
- "illuminate/json-schema": "^12.41.1|^13.0",
- "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
- "illuminate/support": "^11.45.3|^12.41.1|^13.0",
- "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
- "php": "^8.2"
- },
- "require-dev": {
- "laravel/pint": "^1.20",
- "orchestra/testbench": "^9.15|^10.8|^11.0",
- "pestphp/pest": "^3.8.5|^4.3.2",
- "phpstan/phpstan": "^2.1.27",
- "rector/rector": "^2.2.4"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "aliases": {
- "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
- },
- "providers": [
- "Laravel\\Mcp\\Server\\McpServiceProvider"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Mcp\\": "src/",
- "Laravel\\Mcp\\Server\\": "src/Server/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Rapidly build MCP servers for your Laravel applications.",
- "homepage": "https://github.com/laravel/mcp",
- "keywords": [
- "laravel",
- "mcp"
- ],
- "support": {
- "issues": "https://github.com/laravel/mcp/issues",
- "source": "https://github.com/laravel/mcp"
- },
- "time": "2026-03-19T12:37:13+00:00"
- },
{
"name": "laravel/pint",
"version": "v1.29.0",
@@ -17311,5 +17311,5 @@
"php": "^8.4"
},
"platform-dev": {},
- "plugin-api-version": "2.9.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/conductor.json b/conductor.json
deleted file mode 100644
index 688de3a90..000000000
--- a/conductor.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "scripts": {
- "setup": "./scripts/conductor-setup.sh",
- "run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
- },
- "runScriptMode": "nonconcurrent"
-}
\ No newline at end of file
diff --git a/config/constants.php b/config/constants.php
index 743b5e38c..dedafa7d5 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,9 +2,10 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.474',
+ 'version' => '4.1.0',
'helper_version' => '1.0.13',
- 'realtime_version' => '1.0.13',
+ 'realtime_version' => '1.0.14',
+ 'railpack_version' => '0.22.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
diff --git a/config/deprecations.php b/config/deprecations.php
new file mode 100644
index 000000000..551b562fa
--- /dev/null
+++ b/config/deprecations.php
@@ -0,0 +1,5 @@
+ 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.',
+];
diff --git a/config/logging.php b/config/logging.php
index 1dbb1135f..05cf8e13d 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
+
+ 'audit' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/audit.log'),
+ 'level' => env('LOG_AUDIT_LEVEL', 'info'),
+ 'days' => env('LOG_AUDIT_DAYS', 90),
+ 'replace_placeholders' => true,
+ ],
],
];
diff --git a/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php
new file mode 100644
index 000000000..f24548142
--- /dev/null
+++ b/database/migrations/2026_04_22_183029_add_is_mcp_server_enabled_to_instance_settings_table.php
@@ -0,0 +1,28 @@
+boolean('is_mcp_server_enabled')->default(false);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('instance_settings', function (Blueprint $table) {
+ $table->dropColumn('is_mcp_server_enabled');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
new file mode 100644
index 000000000..1700feebc
--- /dev/null
+++ b/database/migrations/2026_04_28_151408_add_connection_timeout_to_server_settings.php
@@ -0,0 +1,22 @@
+integer('connection_timeout')->default(10)->after('deployment_queue_limit');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('server_settings', function (Blueprint $table) {
+ $table->dropColumn('connection_timeout');
+ });
+ }
+};
diff --git a/database/seeders/ApplicationSeeder.php b/database/seeders/ApplicationSeeder.php
index 2a0273e0f..212bcce79 100644
--- a/database/seeders/ApplicationSeeder.php
+++ b/database/seeders/ApplicationSeeder.php
@@ -47,6 +47,22 @@ public function run(): void
'source_id' => 1,
'source_type' => GithubApp::class,
]);
+ Application::create([
+ 'uuid' => 'railpack-nodejs',
+ 'name' => 'Railpack NodeJS Fastify Example',
+ 'fqdn' => 'http://railpack-nodejs.127.0.0.1.sslip.io',
+ 'repository_project_id' => 603035348,
+ 'git_repository' => 'coollabsio/coolify-examples',
+ 'git_branch' => 'v4.x',
+ 'base_directory' => '/nodejs',
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => '3000',
+ 'environment_id' => 1,
+ 'destination_id' => 0,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 1,
+ 'source_type' => GithubApp::class,
+ ]);
Application::create([
'uuid' => 'dockerfile',
'name' => 'Dockerfile Example',
@@ -145,5 +161,21 @@ public function run(): void
'source_id' => 1,
'source_type' => GitlabApp::class,
]);
+ Application::create([
+ 'uuid' => 'railpack-static',
+ 'name' => 'Railpack Static Example',
+ 'fqdn' => 'http://railpack-static.127.0.0.1.sslip.io',
+ 'repository_project_id' => 603035348,
+ 'git_repository' => 'coollabsio/coolify-examples',
+ 'git_branch' => 'v4.x',
+ 'base_directory' => '/static',
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => '80',
+ 'environment_id' => 1,
+ 'destination_id' => 0,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 1,
+ 'source_type' => GithubApp::class,
+ ]);
}
}
diff --git a/database/seeders/ApplicationSettingsSeeder.php b/database/seeders/ApplicationSettingsSeeder.php
index 87236df8a..e8be0ba70 100644
--- a/database/seeders/ApplicationSettingsSeeder.php
+++ b/database/seeders/ApplicationSettingsSeeder.php
@@ -22,5 +22,12 @@ public function run(): void
$gitlabPublic->settings->is_static = true;
$gitlabPublic->settings->save();
}
+
+ $railpackStatic = Application::where('uuid', 'railpack-static')->first();
+ if ($railpackStatic) {
+ $railpackStatic->load(['settings']);
+ $railpackStatic->settings->is_static = true;
+ $railpackStatic->settings->save();
+ }
}
}
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 57ccab4ae..4f5c4431a 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -31,5 +31,11 @@ public function run(): void
CaSslCertSeeder::class,
PersonalAccessTokenSeeder::class,
]);
+
+ if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
+ $this->call([
+ DevelopmentRailpackExamplesSeeder::class,
+ ]);
+ }
}
}
diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php
new file mode 100644
index 000000000..78659b457
--- /dev/null
+++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php
@@ -0,0 +1,513 @@
+isDevelopmentEnvironment()) {
+ $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.');
+
+ return;
+ }
+
+ $this->ensureDevelopmentPrerequisitesExist();
+ $destination = StandaloneDocker::query()->find(0);
+
+ if (! $destination) {
+ throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.');
+ }
+
+ $environment = $this->prepareEnvironment();
+
+ foreach (self::examples() as $example) {
+ $this->upsertApplication($environment, $destination, $example);
+ }
+ }
+
+ /**
+ * @return array>
+ */
+ public static function examples(): array
+ {
+ return [
+ [
+ 'uuid' => 'railpack-simple-webserver',
+ 'name' => 'Railpack Simple Webserver Example',
+ 'base_directory' => '/node/simple-webserver',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-expressjs',
+ 'name' => 'Railpack Express.js Example',
+ 'base_directory' => '/node/expressjs',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-fastify',
+ 'name' => 'Railpack Fastify Example',
+ 'base_directory' => '/node/fastify',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nestjs',
+ 'name' => 'Railpack NestJS Example',
+ 'base_directory' => '/node/nestjs',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start:prod',
+ ],
+ [
+ 'uuid' => 'railpack-adonisjs',
+ 'name' => 'Railpack AdonisJS Example',
+ 'base_directory' => '/node/adonisjs',
+ 'ports_exposes' => '3333',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-hono',
+ 'name' => 'Railpack Hono Example',
+ 'base_directory' => '/node/hono',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-koa',
+ 'name' => 'Railpack Koa Example',
+ 'base_directory' => '/node/koa',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-ssr',
+ 'name' => 'Railpack Next.js SSR Example',
+ 'base_directory' => '/node/nextjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-ssr',
+ 'name' => 'Railpack NuxtJS SSR Example',
+ 'base_directory' => '/node/nuxtjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000',
+ ],
+ [
+ 'uuid' => 'railpack-astro-ssr',
+ 'name' => 'Railpack Astro SSR Example',
+ 'base_directory' => '/node/astro/ssr',
+ 'ports_exposes' => '4321',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-ssr',
+ 'name' => 'Railpack SvelteKit SSR Example',
+ 'base_directory' => '/node/sveltekit/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-ssr',
+ 'name' => 'Railpack TanStack Start SSR Example',
+ 'base_directory' => '/node/tanstack-start/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-angular-ssr',
+ 'name' => 'Railpack Angular SSR Example',
+ 'base_directory' => '/node/angular/ssr',
+ 'ports_exposes' => '4000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-vue-ssr',
+ 'name' => 'Railpack Vue SSR Example',
+ 'base_directory' => '/node/vue/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-qwik-ssr',
+ 'name' => 'Railpack Qwik SSR Example',
+ 'base_directory' => '/node/qwik/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run serve',
+ ],
+ [
+ 'uuid' => 'railpack-react-static',
+ 'name' => 'Railpack React Static Example',
+ 'base_directory' => '/node/react',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vite-static',
+ 'name' => 'Railpack Vite Static Example',
+ 'base_directory' => '/node/vite',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-eleventy-static',
+ 'name' => 'Railpack Eleventy Static Example',
+ 'base_directory' => '/node/eleventy',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/_site',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-gatsby-static',
+ 'name' => 'Railpack Gatsby Static Example',
+ 'base_directory' => '/node/gatsby',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/public',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-static',
+ 'name' => 'Railpack Next.js Static Example',
+ 'base_directory' => '/node/nextjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/out',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-static',
+ 'name' => 'Railpack NuxtJS Static Example',
+ 'base_directory' => '/node/nuxtjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-astro-static',
+ 'name' => 'Railpack Astro Static Example',
+ 'base_directory' => '/node/astro/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-static',
+ 'name' => 'Railpack SvelteKit Static Example',
+ 'base_directory' => '/node/sveltekit/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/build',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-static',
+ 'name' => 'Railpack TanStack Start Static Example',
+ 'base_directory' => '/node/tanstack-start/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-angular-static',
+ 'name' => 'Railpack Angular Static Example',
+ 'base_directory' => '/node/angular/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist/static/browser',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vue-static',
+ 'name' => 'Railpack Vue Static Example',
+ 'base_directory' => '/node/vue/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-qwik-static',
+ 'name' => 'Railpack Qwik Static Example',
+ 'base_directory' => '/node/qwik/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ // Multi-language examples (only available on v4.x branch).
+ [
+ 'uuid' => 'railpack-python-flask',
+ 'name' => 'Railpack Python Flask Example',
+ 'base_directory' => '/flask',
+ 'ports_exposes' => '5000',
+ 'git_branch' => 'v4.x',
+ 'start_command' => 'flask run --host=0.0.0.0 --port=5000',
+ ],
+ [
+ 'uuid' => 'railpack-go-gin',
+ 'name' => 'Railpack Go Gin Example',
+ 'base_directory' => '/go/gin',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rust',
+ 'name' => 'Railpack Rust Example',
+ 'base_directory' => '/rust',
+ 'ports_exposes' => '8000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel',
+ 'name' => 'Railpack Laravel Example',
+ 'base_directory' => '/laravel',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-pure',
+ 'name' => 'Railpack Laravel Pure Example',
+ 'base_directory' => '/laravel-pure',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-inertia',
+ 'name' => 'Railpack Laravel Inertia Example',
+ 'base_directory' => '/laravel-inertia',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-symfony',
+ 'name' => 'Railpack Symfony Example',
+ 'base_directory' => '/symfony',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rails',
+ 'name' => 'Railpack Ruby on Rails Example',
+ 'base_directory' => '/rails-example',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-elixir-phoenix',
+ 'name' => 'Railpack Elixir Phoenix Example',
+ 'base_directory' => '/elixir-phoenix',
+ 'ports_exposes' => '4000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-bun',
+ 'name' => 'Railpack Bun Example',
+ 'base_directory' => '/bun',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ ];
+ }
+
+ private function ensureDevelopmentPrerequisitesExist(): void
+ {
+ Team::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'name' => 'Root Team',
+ 'description' => 'The root team',
+ 'personal_team' => true,
+ ],
+ );
+
+ PrivateKey::query()->firstOrCreate(
+ ['id' => 1],
+ [
+ 'uuid' => 'ssh',
+ 'team_id' => 0,
+ 'name' => 'Testing Host Key',
+ 'description' => 'This is a test docker container',
+ 'private_key' => <<<'KEY'
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----
+KEY,
+ ],
+ );
+
+ Server::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'localhost',
+ 'name' => 'localhost',
+ 'description' => 'This is a test docker container in development mode',
+ 'ip' => 'coolify-testing-host',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ],
+ );
+
+ StandaloneDocker::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'docker',
+ 'name' => 'Standalone Docker 1',
+ 'network' => 'coolify',
+ 'server_id' => 0,
+ ],
+ );
+
+ $this->ensurePublicGithubSourceExists();
+ }
+
+ private function ensurePublicGithubSourceExists(): void
+ {
+ GithubApp::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'github-public',
+ 'name' => 'Public GitHub',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'is_public' => true,
+ 'team_id' => 0,
+ ],
+ );
+ }
+
+ private function isDevelopmentEnvironment(): bool
+ {
+ return in_array(config('app.env'), ['local', 'development', 'dev'], true);
+ }
+
+ private function prepareEnvironment(): Environment
+ {
+ $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]);
+ $project->fill([
+ 'name' => 'Railpack Examples',
+ 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.',
+ 'team_id' => 0,
+ ]);
+ $project->save();
+
+ $environment = $project->environments()->first();
+
+ if (! $environment) {
+ $environment = $project->environments()->create([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ } else {
+ $environment->update([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ }
+
+ return $environment;
+ }
+
+ /**
+ * @param array $example
+ */
+ private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void
+ {
+ $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]);
+ $application->fill([
+ 'name' => $example['name'],
+ 'description' => $example['name'],
+ 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io",
+ 'repository_project_id' => self::REPOSITORY_PROJECT_ID,
+ 'git_repository' => self::GIT_REPOSITORY,
+ 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH,
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => $example['ports_exposes'],
+ 'base_directory' => $example['base_directory'],
+ 'publish_directory' => $example['publish_directory'] ?? null,
+ 'static_image' => 'nginx:alpine',
+ 'install_command' => $example['install_command'] ?? null,
+ 'build_command' => $example['build_command'] ?? null,
+ 'start_command' => $example['start_command'] ?? null,
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 0,
+ 'source_type' => GithubApp::class,
+ ]);
+ $application->save();
+
+ if ($application->trashed()) {
+ $application->restore();
+ }
+
+ $application->settings()->updateOrCreate(
+ ['application_id' => $application->id],
+ [
+ 'is_static' => $example['is_static'] ?? false,
+ 'is_spa' => $example['is_spa'] ?? false,
+ ],
+ );
+ }
+}
diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php
index baa7abffc..930a7db8e 100644
--- a/database/seeders/InstanceSettingsSeeder.php
+++ b/database/seeders/InstanceSettingsSeeder.php
@@ -23,23 +23,25 @@ public function run(): void
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
]);
- try {
- $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
- $ipv4 = trim($ipv4);
- $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv4) && $ipv4) {
- $settings->update(['public_ipv4' => $ipv4]);
+ if (! isDev()) {
+ try {
+ $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
+ $ipv4 = trim($ipv4);
+ $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv4) && $ipv4) {
+ $settings->update(['public_ipv4' => $ipv4]);
+ }
+ $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
+ $ipv6 = trim($ipv6);
+ $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv6) && $ipv6) {
+ $settings->update(['public_ipv6' => $ipv6]);
+ }
+ } catch (\Throwable $e) {
+ echo "Error: {$e->getMessage()}\n";
}
- $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
- $ipv6 = trim($ipv6);
- $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv6) && $ipv6) {
- $settings->update(['public_ipv6' => $ipv6]);
- }
- } catch (\Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
}
}
}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f608fe3cb..50edc140f 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -129,10 +129,9 @@ services:
networks:
- coolify
minio:
- image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
+ image: ghcr.io/coollabsio/maxio:latest
pull_policy: always
container_name: coolify-minio
- command: server /data --console-address ":9001"
ports:
- "${FORWARD_MINIO_PORT:-9000}:9000"
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 9c984a5ee..263c5a311 100644
--- a/docker/coolify-helper/Dockerfile
+++ b/docker/coolify-helper/Dockerfile
@@ -11,6 +11,10 @@ ARG DOCKER_BUILDX_VERSION=0.25.0
ARG PACK_VERSION=0.38.2
# https://github.com/railwayapp/nixpacks/releases
ARG NIXPACKS_VERSION=1.41.0
+# https://github.com/railwayapp/railpack/releases
+ARG RAILPACK_VERSION=0.22.0
+# https://github.com/jdx/mise/releases — must match railpack's pinned version (https://raw.githubusercontent.com/railwayapp/railpack/refs/heads/main/core/mise/version.txt)
+ARG MISE_VERSION=2026.3.12
# https://github.com/minio/mc/releases
ARG MINIO_VERSION=RELEASE.2025-08-13T08-35-41Z
@@ -25,18 +29,34 @@ ARG DOCKER_COMPOSE_VERSION
ARG DOCKER_BUILDX_VERSION
ARG PACK_VERSION
ARG NIXPACKS_VERSION
+ARG RAILPACK_VERSION
+ARG MISE_VERSION
USER root
WORKDIR /artifacts
+ENV RAILPACK_VERSION=${RAILPACK_VERSION}
RUN apk upgrade --no-cache && \
apk add --no-cache bash curl git git-lfs openssh-client tar tini
RUN mkdir -p ~/.docker/cli-plugins
+
+# Install mise (musl build) at the path railpack expects (/tmp/railpack/mise/mise-VERSION).
+# Railpack hardcodes a glibc mise download that fails on Alpine, so we pre-place a musl binary.
+RUN mkdir -p /tmp/railpack/mise && \
+ if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
+ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-x64-musl.tar.gz" | tar xz && \
+ mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
+ elif [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
+ curl -sSL "https://github.com/jdx/mise/releases/download/v${MISE_VERSION}/mise-v${MISE_VERSION}-linux-arm64-musl.tar.gz" | tar xz && \
+ mv mise/bin/mise "/tmp/railpack/mise/mise-${MISE_VERSION}" && rm -rf mise; \
+ fi
+
RUN if [[ ${TARGETPLATFORM} == 'linux/amd64' ]]; then \
curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-amd64 -o ~/.docker/cli-plugins/docker-buildx && \
curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose && \
(curl -sSL https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
+ curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi
@@ -46,6 +66,7 @@ RUN if [[ ${TARGETPLATFORM} == 'linux/arm64' ]]; then \
(curl -sSL https://download.docker.com/linux/static/stable/aarch64/docker-${DOCKER_VERSION}.tgz | tar -C /usr/bin/ --no-same-owner -xzv --strip-components=1 docker/docker) && \
(curl -sSL https://github.com/buildpacks/pack/releases/download/v${PACK_VERSION}/pack-v${PACK_VERSION}-linux-arm64.tgz | tar -C /usr/local/bin/ --no-same-owner -xzv pack) && \
curl -sSL https://nixpacks.com/install.sh | bash && \
+ curl -sSL https://railpack.com/install.sh | RAILPACK_VERSION=${RAILPACK_VERSION} sh && \
chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /usr/local/bin/pack /root/.docker/cli-plugins/docker-buildx \
;fi
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 174077562..eae81be6a 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -165,9 +165,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 3ae77857f..f5760279f 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
+const HEARTBEAT_INTERVAL_MS = 30000;
+const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+
wss.on('connection', async (ws, req) => {
+ ws.isAlive = true;
+ ws.on('pong', () => { ws.isAlive = true; });
+
const userId = generateUserId();
- const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
+ ws.userId = userId;
+ const userSession = {
+ ws,
+ userId,
+ ptyProcess: null,
+ isActive: false,
+ authorizedIPs: [],
+ lastActivityAt: Date.now(),
+ authReady: false,
+ pendingMessages: [],
+ };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => {
hasLaravelSession: Boolean(laravelSession),
};
+ // Register socket handlers up front so messages sent immediately by the client
+ // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
+ // below is still pending.
+ ws.on('message', (message) => {
+ if (userSession.authReady) {
+ handleMessage(userSession, message);
+ } else {
+ userSession.pendingMessages.push(message);
+ }
+ });
+ ws.on('error', (err) => handleError(err, userId));
+ ws.on('close', (code, reason) => {
+ logTerminal('log', 'Terminal websocket connection closed.', {
+ userId,
+ code,
+ reason: reason?.toString(),
+ });
+ handleClose(userId);
+ });
+
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
@@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => {
}
userSessions.set(userId, userSession);
+ userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
+ bufferedMessages: userSession.pendingMessages.length,
});
- ws.on('message', (message) => {
- handleMessage(userSession, message);
- });
- ws.on('error', (err) => handleError(err, userId));
- ws.on('close', (code, reason) => {
- logTerminal('log', 'Terminal websocket connection closed.', {
- userId,
- code,
- reason: reason?.toString(),
- });
- handleClose(userId);
- });
+ // Drain any messages that arrived while we were waiting on the IP auth call.
+ while (userSession.pendingMessages.length > 0) {
+ handleMessage(userSession, userSession.pendingMessages.shift());
+ }
});
+const heartbeat = setInterval(() => {
+ wss.clients.forEach((ws) => {
+ if (ws.isAlive === false) {
+ logTerminal('warn', 'Terminating WS due to missed protocol pong.');
+ return ws.terminate();
+ }
+ ws.isAlive = false;
+ try {
+ ws.ping();
+ } catch (_) {
+ // ignore — close handler will follow
+ }
+
+ const session = ws.userId ? userSessions.get(ws.userId) : null;
+ if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
+ const idleMs = Date.now() - session.lastActivityAt;
+ logTerminal('warn', 'Closing terminal session due to idle timeout.', {
+ userId: ws.userId,
+ idleMs,
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
+ });
+ try {
+ ws.send('idle-timeout');
+ } catch (_) {
+ // ignore — close still attempted below
+ }
+ killPtyProcess(ws.userId);
+ setTimeout(() => {
+ try {
+ ws.close(1000, 'Idle timeout');
+ } catch (_) {
+ // ignore — already closed
+ }
+ }, 100);
+ }
+ });
+}, HEARTBEAT_INTERVAL_MS);
+
+wss.on('close', () => clearInterval(heartbeat));
+
const messageHandlers = {
- message: (session, data) => session.ptyProcess.write(data),
+ message: (session, data) => {
+ session.lastActivityAt = Date.now();
+ session.ptyProcess.write(data);
+ },
resize: (session, { cols, rows }) => {
+ session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@@ -197,12 +271,6 @@ function handleMessage(userSession, message) {
return;
}
- logTerminal('log', 'Received websocket message.', {
- userId: userSession.userId,
- keys: Object.keys(parsed),
- isActive: userSession.isActive,
- });
-
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
@@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
+ userSession.lastActivityAt = Date.now();
ws.send('pty-ready');
diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile
index 77013e1b9..dc9a06c1e 100644
--- a/docker/development/Dockerfile
+++ b/docker/development/Dockerfile
@@ -38,6 +38,8 @@ RUN apk upgrade --no-cache && \
mkdir -p /usr/share/keyrings && \
curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg
+RUN sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories
+
# Install system dependencies
RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
diff --git a/openapi.json b/openapi.json
index d83b30d80..25aada1e1 100644
--- a/openapi.json
+++ b/openapi.json
@@ -111,6 +111,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -569,6 +570,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -1019,6 +1021,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -1448,10 +1451,7 @@
"build_pack": {
"type": "string",
"enum": [
- "nixpacks",
- "static",
- "dockerfile",
- "dockercompose"
+ "dockerfile"
],
"description": "The build pack type."
},
@@ -2092,173 +2092,6 @@
]
}
},
- "\/applications\/dockercompose": {
- "post": {
- "tags": [
- "Applications"
- ],
- "summary": "Create (Docker Compose)",
- "description": "Deprecated: Use POST \/api\/v1\/services instead.",
- "operationId": "create-dockercompose-application",
- "requestBody": {
- "description": "Application object that needs to be created.",
- "required": true,
- "content": {
- "application\/json": {
- "schema": {
- "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."
- }
- },
- "type": "object"
- }
- }
- }
- },
- "responses": {
- "201": {
- "description": "Application created successfully.",
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "uuid": {
- "type": "string"
- }
- },
- "type": "object"
- }
- }
- }
- },
- "401": {
- "$ref": "#\/components\/responses\/401"
- },
- "400": {
- "$ref": "#\/components\/responses\/400"
- },
- "409": {
- "description": "Domain conflicts detected.",
- "content": {
- "application\/json": {
- "schema": {
- "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": {
- "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'"
- }
- },
- "type": "object"
- }
- }
- },
- "type": "object"
- }
- }
- }
- }
- },
- "deprecated": true,
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
"\/applications\/{uuid}": {
"get": {
"tags": [
@@ -2457,6 +2290,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -4381,8 +4215,8 @@
"description": "Number of days to retain backups locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
- "description": "Max storage (MB) for local backups"
+ "type": "number",
+ "description": "Max storage (GB) for local backups"
},
"database_backup_retention_amount_s3": {
"type": "integer",
@@ -4393,8 +4227,8 @@
"description": "Number of days to retain backups in S3"
},
"database_backup_retention_max_storage_s3": {
- "type": "integer",
- "description": "Max storage (MB) for S3 backups"
+ "type": "number",
+ "description": "Max storage (GB) for S3 backups"
},
"timeout": {
"type": "integer",
@@ -4951,7 +4785,7 @@
"description": "Retention days of the backup locally"
},
"database_backup_retention_max_storage_locally": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup locally"
},
"database_backup_retention_amount_s3": {
@@ -4963,7 +4797,7 @@
"description": "Retention days of the backup in s3"
},
"database_backup_retention_max_storage_s3": {
- "type": "integer",
+ "type": "number",
"description": "Max storage of the backup in S3"
},
"timeout": {
@@ -8650,6 +8484,110 @@
]
}
},
+ "\/mcp\/enable": {
+ "post": {
+ "summary": "Enable MCP Server",
+ "description": "Enable the MCP server endpoint at \/mcp (only with root permissions).",
+ "operationId": "enable-mcp",
+ "responses": {
+ "200": {
+ "description": "MCP server enabled.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "MCP server enabled."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "You are not allowed to enable the MCP server.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You are not allowed to enable the MCP server."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/mcp\/disable": {
+ "post": {
+ "summary": "Disable MCP Server",
+ "description": "Disable the MCP server endpoint at \/mcp (only with root permissions).",
+ "operationId": "disable-mcp",
+ "responses": {
+ "200": {
+ "description": "MCP server disabled.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "MCP server disabled."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "403": {
+ "description": "You are not allowed to disable the MCP server.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string",
+ "example": "You are not allowed to disable the MCP server."
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/health": {
"get": {
"summary": "Healthcheck",
@@ -10545,6 +10483,10 @@
"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."
}
},
"type": "object"
@@ -12499,6 +12441,7 @@
"description": "Build pack.",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -13349,6 +13292,10 @@
"delete_unused_networks": {
"type": "boolean",
"description": "The flag to indicate if the unused networks should be deleted."
+ },
+ "connection_timeout": {
+ "type": "integer",
+ "description": "SSH connection timeout in seconds."
}
},
"type": "object"
diff --git a/openapi.yaml b/openapi.yaml
index aab408098..4597b06f7 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -81,7 +81,7 @@ paths:
description: 'The git branch.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
ports_exposes:
type: string
@@ -375,7 +375,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -663,7 +663,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -935,7 +935,7 @@ paths:
description: 'The Dockerfile content.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [dockerfile]
description: 'The build pack type.'
ports_exposes:
type: string
@@ -1337,95 +1337,6 @@ paths:
security:
-
bearerAuth: []
- /applications/dockercompose:
- post:
- tags:
- - Applications
- summary: 'Create (Docker Compose)'
- description: 'Deprecated: Use POST /api/v1/services instead.'
- operationId: create-dockercompose-application
- requestBody:
- description: 'Application object that needs to be created.'
- required: true
- content:
- application/json:
- schema:
- 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.'
- type: object
- responses:
- '201':
- description: 'Application created successfully.'
- content:
- application/json:
- schema:
- properties:
- uuid: { type: string }
- type: object
- '401':
- $ref: '#/components/responses/401'
- '400':
- $ref: '#/components/responses/400'
- '409':
- description: 'Domain conflicts detected.'
- content:
- application/json:
- schema:
- 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: { 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'" } }, type: object } }
- type: object
- deprecated: true
- security:
- -
- bearerAuth: []
'/applications/{uuid}':
get:
tags:
@@ -1568,7 +1479,7 @@ paths:
description: 'The destination UUID.'
build_pack:
type: string
- enum: [nixpacks, static, dockerfile, dockercompose]
+ enum: [nixpacks, railpack, static, dockerfile, dockercompose]
description: 'The build pack type.'
name:
type: string
@@ -2765,8 +2676,8 @@ paths:
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'
+ type: number
+ description: 'Max storage (GB) for local backups'
database_backup_retention_amount_s3:
type: integer
description: 'Number of backups to retain in S3'
@@ -2774,8 +2685,8 @@ paths:
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'
+ type: number
+ description: 'Max storage (GB) for S3 backups'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
@@ -3160,7 +3071,7 @@ paths:
type: integer
description: 'Retention days of the backup locally'
database_backup_retention_max_storage_locally:
- type: integer
+ type: number
description: 'Max storage of the backup locally'
database_backup_retention_amount_s3:
type: integer
@@ -3169,7 +3080,7 @@ paths:
type: integer
description: 'Retention days of the backup in s3'
database_backup_retention_max_storage_s3:
- type: integer
+ type: number
description: 'Max storage of the backup in S3'
timeout:
type: integer
@@ -5484,6 +5395,64 @@ paths:
security:
-
bearerAuth: []
+ /mcp/enable:
+ post:
+ summary: 'Enable MCP Server'
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).'
+ operationId: enable-mcp
+ responses:
+ '200':
+ description: 'MCP server enabled.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'MCP server enabled.' }
+ type: object
+ '403':
+ description: 'You are not allowed to enable the MCP server.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'You are not allowed to enable the MCP server.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
+ /mcp/disable:
+ post:
+ summary: 'Disable MCP Server'
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).'
+ operationId: disable-mcp
+ responses:
+ '200':
+ description: 'MCP server disabled.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'MCP server disabled.' }
+ type: object
+ '403':
+ description: 'You are not allowed to disable the MCP server.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string, example: 'You are not allowed to disable the MCP server.' }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ security:
+ -
+ bearerAuth: []
/health:
get:
summary: Healthcheck
@@ -6734,6 +6703,9 @@ paths:
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.'
type: object
responses:
'201':
@@ -7916,6 +7888,7 @@ components:
description: 'Build pack.'
enum:
- nixpacks
+ - railpack
- static
- dockerfile
- dockercompose
@@ -8538,6 +8511,9 @@ components:
delete_unused_networks:
type: boolean
description: 'The flag to indicate if the unused networks should be deleted.'
+ connection_timeout:
+ type: integer
+ description: 'SSH connection timeout in seconds.'
type: object
Service:
description: 'Service model'
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 27d911c67..f8b4ea890 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.474"
+ "version": "4.1.0"
},
"nightly": {
"version": "4.0.0"
@@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
- "version": "1.0.13"
+ "version": "1.0.14"
},
"sentinel": {
"version": "0.0.21"
diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png
new file mode 100644
index 000000000..4b6a7df14
Binary files /dev/null and b/public/svgs/cap-captcha.png differ
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index 9199a22ab..a0e4ee2ca 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -347,3 +347,12 @@ @utility log-debug {
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}
+
+@media (min-width: 1024px) {
+ .sidebar-collapsed .menu-item {
+ justify-content: center;
+ padding-left: 0;
+ padding-right: 0;
+ gap: 0;
+ }
+}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index aa5f37353..7a7fc8536 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
+ // Last successfully sent SSH command — replayed after a transient reconnect
+ // so the PTY respawns automatically. Cleared on intentional terminations
+ // (pty-exited, idle-timeout, unprocessable).
+ lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
- this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
-
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
- [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
- .forEach(timer => timer && clearInterval(timer));
+ if (this.keepAliveInterval) {
+ clearInterval(this.keepAliveInterval);
+ }
+ [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
+ .forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
resetTerminal() {
if (this.term) {
- this.$wire.dispatch('error', 'Terminal websocket connection lost.');
- this.term.reset();
- this.term.clear();
+ this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
+ // Preserve scrollback so the user keeps the context of their previous
+ // session. Print a visible marker so they know where the disconnect
+ // happened. Old PTY shell state cannot be restored — this is purely
+ // a visual carry-over.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — terminal not ready to receive writes
+ }
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
@@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
- // Flush any buffered command from before WebSocket was ready
+ // Flush any buffered command from before WebSocket was ready, otherwise
+ // replay the last command so a transient reconnect respawns the PTY
+ // automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
+ } else if (this.lastSentCommand) {
+ logTerminal('log', '[Terminal] Replaying last command after reconnect.');
+ this.sendMessage(this.lastSentCommand);
+ }
+
+ // (Re)start application-level keepalive on every successful connect.
+ // Server-side WebSocket protocol pings are the primary heartbeat; this
+ // adds a JSON-level ping in case the server-side is older or restarting.
+ if (!this.keepAliveInterval) {
+ this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
@@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
+ if (message && message.command) {
+ this.lastSentCommand = message;
+ }
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
},
handleSocketMessage(event) {
- logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
-
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
@@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
- this.term.reset();
+ // Already initialized — this is a reconnect or a follow-up command.
+ // Preserve scrollback so the user keeps context. Write a visible
+ // separator so the new shell prompt is easy to spot.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — fall through; xterm will render the new prompt anyway
+ }
}
this.terminalActive = true;
this.term.focus();
@@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
+ this.lastSentCommand = null;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
@@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
+ this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
+ } else if (event.data === 'idle-timeout') {
+ this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
+ this.terminalActive = false;
+ if (this.term) {
+ this.term.reset();
+ }
+ this.commandBuffer = '';
+ this.lastSentCommand = null;
+ this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
- // Skip keepalive when document is hidden to prevent unnecessary disconnects
- if (!this.isDocumentVisible) {
- return;
- }
-
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
- // Send immediate ping to verify connection is still alive
+ // Connection may be half-open after Cloudflare/proxy idle drop while hidden.
+ // Probe with a short timeout (5s) instead of the default 35s — force a
+ // reconnect quickly if no pong arrives so the user is not stuck typing
+ // into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
- this.resetPingTimeout();
+ if (this.pingTimeoutId) {
+ clearTimeout(this.pingTimeoutId);
+ }
+ this.pingTimeoutId = setTimeout(() => {
+ logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
+ try {
+ this.socket.close(4000, 'Visibility-resume timeout');
+ } catch (_) {
+ // ignore — close handler will run on its own
+ }
+ }, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
diff --git a/resources/views/components/deprecated-badge.blade.php b/resources/views/components/deprecated-badge.blade.php
new file mode 100644
index 000000000..9a797048d
--- /dev/null
+++ b/resources/views/components/deprecated-badge.blade.php
@@ -0,0 +1,6 @@
+merge(['class' => 'inline-flex items-center']) }}>
+
+ Deprecated
+
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 642bbcfb0..f637425c1 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -229,7 +229,7 @@ class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dar
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
- wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
+ wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@endif
wire:loading.attr="disabled"
@disabled($disabled)
diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php
index 394f6275f..2542839f1 100644
--- a/resources/views/components/helper.blade.php
+++ b/resources/views/components/helper.blade.php
@@ -1,4 +1,5 @@
-merge(['class' => 'group']) }}>
+
merge(['class' => 'group']) }}>
@isset($icon)
{{ $icon }}
@@ -10,7 +11,7 @@
@endisset
-