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/.env.development.example b/.env.development.example
index 594b89201..d02b8ba59 100644
--- a/.env.development.example
+++ b/.env.development.example
@@ -15,6 +15,18 @@ DB_PASSWORD=password
DB_HOST=host.docker.internal
DB_PORT=5432
+# Read/write replicas (optional). Set DB_READ_HOST to enable the read/write split.
+# Hosts may be comma-separated. Port/username/password fall back to DB_* when unset.
+# DB_READ_HOST=replica1,replica2
+# DB_READ_PORT=5432
+# DB_READ_USERNAME=coolify
+# DB_READ_PASSWORD=
+# DB_WRITE_HOST=
+# DB_WRITE_PORT=5432
+# DB_WRITE_USERNAME=coolify
+# DB_WRITE_PASSWORD=
+# DB_STICKY=true
+
# Ray Configuration
# Set to true to enable Ray
RAY_ENABLED=false
diff --git a/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 bb65da405..188889954 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,6 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.
diff --git a/README.md b/README.md
index 9a5feff4e..b387d87e8 100644
--- a/README.md
+++ b/README.md
@@ -59,8 +59,9 @@ Thank you so much!
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
+* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
-*
+* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
### Big Sponsors
@@ -69,13 +70,12 @@ Thank you so much!
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
-* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
+* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times award–winning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
-* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
@@ -87,6 +87,7 @@ Thank you so much!
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
+* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
@@ -151,6 +152,10 @@ Thank you so much!
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index e86e30f04..bfad20ccf 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -13,7 +13,7 @@ class StopApplication
public string $jobQueue = 'high';
- public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true)
+ public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true, bool $resetRestartCount = true)
{
$servers = collect([$application->destination->server]);
if ($application?->additional_servers?->count() > 0) {
@@ -36,10 +36,11 @@ class StopApplication
: getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray();
+ $timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
@@ -56,12 +57,17 @@ class StopApplication
}
}
- // Reset restart tracking when application is manually stopped
- $application->update([
- 'restart_count' => 0,
- 'last_restart_at' => null,
- 'last_restart_type' => null,
- ]);
+ if ($resetRestartCount) {
+ $application->update([
+ 'restart_count' => 0,
+ 'last_restart_at' => null,
+ 'last_restart_type' => null,
+ ]);
+ } else {
+ $application->update([
+ 'status' => 'exited',
+ ]);
+ }
ServiceStatusChanged::dispatch($application->environment->project->team->id);
}
diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php
index bf9fdee72..09de9b628 100644
--- a/app/Actions/Application/StopApplicationOneServer.php
+++ b/app/Actions/Application/StopApplicationOneServer.php
@@ -20,13 +20,15 @@ class StopApplicationOneServer
}
try {
$containers = getCurrentApplicationContainerStatus($server, $application->id, 0);
+ $timeout = $application->settings->stopGracePeriodSeconds();
+
if ($containers->count() > 0) {
foreach ($containers as $container) {
$containerName = data_get($container, 'Names');
if ($containerName) {
instant_remote_process(
[
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
],
$server
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 393906b9b..525e736c3 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -50,13 +50,9 @@ class StartClickhouse
],
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -98,6 +94,9 @@ class StartClickhouse
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartDatabase.php b/app/Actions/Database/StartDatabase.php
index e2fa6fc87..4b55b0c1d 100644
--- a/app/Actions/Database/StartDatabase.php
+++ b/app/Actions/Database/StartDatabase.php
@@ -11,12 +11,16 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
class StartDatabase
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
{
@@ -25,28 +29,28 @@ class StartDatabase
return 'Server is not functional';
}
switch ($database->getMorphClass()) {
- case \App\Models\StandalonePostgresql::class:
+ case StandalonePostgresql::class:
$activity = StartPostgresql::run($database);
break;
- case \App\Models\StandaloneRedis::class:
+ case StandaloneRedis::class:
$activity = StartRedis::run($database);
break;
- case \App\Models\StandaloneMongodb::class:
+ case StandaloneMongodb::class:
$activity = StartMongodb::run($database);
break;
- case \App\Models\StandaloneMysql::class:
+ case StandaloneMysql::class:
$activity = StartMysql::run($database);
break;
- case \App\Models\StandaloneMariadb::class:
+ case StandaloneMariadb::class:
$activity = StartMariadb::run($database);
break;
- case \App\Models\StandaloneKeydb::class:
+ case StandaloneKeydb::class:
$activity = StartKeydb::run($database);
break;
- case \App\Models\StandaloneDragonfly::class:
+ case StandaloneDragonfly::class:
$activity = StartDragonfly::run($database);
break;
- case \App\Models\StandaloneClickhouse::class:
+ case StandaloneClickhouse::class:
$activity = StartClickhouse::run($database);
break;
}
diff --git a/app/Actions/Database/StartDatabaseProxy.php b/app/Actions/Database/StartDatabaseProxy.php
index fa39f7909..1057d1e4d 100644
--- a/app/Actions/Database/StartDatabaseProxy.php
+++ b/app/Actions/Database/StartDatabaseProxy.php
@@ -11,14 +11,19 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Notifications\Container\ContainerRestarted;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartDatabaseProxy
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|ServiceDatabase $database)
{
@@ -29,7 +34,7 @@ class StartDatabaseProxy
$proxyContainerName = "{$database->uuid}-proxy";
$isSSLEnabled = $database->enable_ssl ?? false;
- if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($database->getMorphClass() === ServiceDatabase::class) {
$databaseType = $database->databaseType();
$network = $database->service->uuid;
$server = data_get($database, 'service.destination.server');
@@ -132,7 +137,7 @@ class StartDatabaseProxy
?? data_get($database, 'service.environment.project.team');
$team?->notify(
- new \App\Notifications\Container\ContainerRestarted(
+ new ContainerRestarted(
"TCP Proxy for {$database->name} database has been disabled due to error: {$e->getMessage()}",
$server,
)
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index cd820523d..b78a0987d 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -106,13 +106,9 @@ class StartDragonfly
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -182,6 +178,9 @@ class StartDragonfly
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index fe80a7d54..89258fe24 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -108,13 +108,9 @@ class StartKeydb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -166,7 +162,7 @@ class StartKeydb
$docker_compose['volumes'] = $volume_names;
}
- if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
+ if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@@ -197,6 +193,9 @@ class StartKeydb
// Add custom docker run options
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 498ba0b0b..2e8faea9a 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -103,13 +103,9 @@ class StartMariadb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'healthcheck.sh', '--connect', '--innodb_initialized'],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'healthcheck.sh', '--connect', '--innodb_initialized',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -175,7 +171,7 @@ class StartMariadb
);
}
- if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
+ if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[
@@ -202,6 +198,9 @@ class StartMariadb
];
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 9565990c1..80ec812a1 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -109,17 +109,11 @@ class StartMongodb
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD',
- 'echo',
- 'ok',
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD',
+ 'echo',
+ 'ok',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -253,6 +247,9 @@ class StartMongodb
$docker_compose['services'][$container_name]['command'] = $commandParts;
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -340,7 +337,10 @@ class StartMongodb
private function add_default_database()
{
- $content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
+ $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
+ $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
+ $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
+ $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 337516405..0445bddcd 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -103,13 +103,9 @@ class StartMysql
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}"],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -175,7 +171,7 @@ class StartMysql
);
}
- if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
+ if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@@ -203,6 +199,9 @@ class StartMysql
];
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -215,7 +214,8 @@ class StartMysql
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
+ $mysqlUser = escapeshellarg($this->database->mysql_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
}
$this->commands[] = "echo 'Database started.'";
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 41e39c811..ae7ae9860 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -110,16 +110,9 @@ class StartPostgresql
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD-SHELL',
- "psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -216,6 +209,9 @@ class StartPostgresql
$docker_compose['services'][$container_name]['command'] = $command;
}
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
@@ -227,7 +223,8 @@ class StartPostgresql
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
+ $postgresUser = escapeshellarg($this->database->postgres_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
}
$this->commands[] = "echo 'Database started.'";
@@ -304,9 +301,18 @@ class StartPostgresql
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
+
+ // Normalise filename without rejecting legacy values so previously created
+ // init scripts keep deploying. basename() strips any directory components
+ // (path traversal) and escapeshellarg() contains every shell metacharacter
+ // in the tee target. Livewire / API validate new filenames up front.
+ $filename = basename((string) $filename);
+
+ $target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $escaped_target = escapeshellarg($target_path);
$content_base64 = base64_encode($content);
- $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
- $this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
+ $this->init_scripts[] = $target_path;
}
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 70df91054..64b434821 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -105,17 +105,11 @@ class StartRedis
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => [
- 'CMD-SHELL',
- 'redis-cli',
- 'ping',
- ],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- 'start_period' => '5s',
- ],
+ 'healthcheck' => $this->database->healthCheckConfiguration([
+ 'CMD-SHELL',
+ 'redis-cli',
+ 'ping',
+ ]),
'mem_limit' => $this->database->limits_memory,
'memswap_limit' => $this->database->limits_memory_swap,
'mem_swappiness' => $this->database->limits_memory_swappiness,
@@ -181,7 +175,7 @@ class StartRedis
);
}
- if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
+ if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf',
@@ -194,6 +188,9 @@ class StartRedis
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php
index 5966876c6..904885dfc 100644
--- a/app/Actions/Docker/GetContainersStatus.php
+++ b/app/Actions/Docker/GetContainersStatus.php
@@ -2,6 +2,7 @@
namespace App\Actions\Docker;
+use App\Actions\Application\StopApplication;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Shared\ComplexStatusCheck;
@@ -9,6 +10,7 @@ use App\Events\ServiceChecked;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceDatabase;
+use App\Notifications\Application\RestartLimitReached as ApplicationRestartLimitReached;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
use Illuminate\Support\Arr;
@@ -464,7 +466,9 @@ class GetContainersStatus
}
// Wrap all database updates in a transaction to ensure consistency
- DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses) {
+ $restartLimitReached = false;
+
+ DB::transaction(function () use ($application, $maxRestartCount, $containerStatuses, &$restartLimitReached) {
$previousRestartCount = $application->restart_count ?? 0;
if ($maxRestartCount > $previousRestartCount) {
@@ -475,16 +479,10 @@ class GetContainersStatus
'last_restart_type' => 'crash',
]);
- // Send notification
- $containerName = $application->name;
- $projectUuid = data_get($application, 'environment.project.uuid');
- $environmentName = data_get($application, 'environment.name');
- $applicationUuid = data_get($application, 'uuid');
-
- if ($projectUuid && $applicationUuid && $environmentName) {
- $url = base_url().'/project/'.$projectUuid.'/'.$environmentName.'/application/'.$applicationUuid;
- } else {
- $url = null;
+ // Check if restart limit has been reached
+ $maxAllowedRestarts = $application->max_restart_count ?? 0;
+ if ($maxAllowedRestarts > 0 && $maxRestartCount >= $maxAllowedRestarts && $previousRestartCount < $maxAllowedRestarts) {
+ $restartLimitReached = true;
}
}
@@ -499,6 +497,12 @@ class GetContainersStatus
}
}
});
+
+ if ($restartLimitReached) {
+ $application->refresh();
+ StopApplication::dispatch($application, false, true, false);
+ $application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
+ }
}
}
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 7ea6a871e..cddf66389 100644
--- a/app/Actions/Fortify/CreateNewUser.php
+++ b/app/Actions/Fortify/CreateNewUser.php
@@ -2,6 +2,7 @@
namespace App\Actions\Fortify;
+use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
@@ -44,7 +45,10 @@ class CreateNewUser implements CreatesNewUsers
'password' => Hash::make($input['password']),
]);
$user->save();
- $team = $user->teams()->first();
+ $team = $user->teams()->first() ?? Team::find(0);
+ if ($team !== null && ! $user->teams()->where('team_id', $team->id)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
// Disable registration after first user is created
$settings = instanceSettings();
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 0d9ca0153..06abeb3a6 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -48,9 +48,10 @@ class CleanupDocker
);
$commands = [
- 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
+ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
+ "docker run --rm -v \$HOME/.docker/buildx:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImageWithVersion} docker buildx prune --builder coolify-railpack -af 2>/dev/null || true",
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
"docker images --filter before=$helperImageWithoutPrefixVersion --filter reference=$helperImageWithoutPrefix | grep $helperImageWithoutPrefix | awk '{print $3}' | xargs -r docker rmi -f",
diff --git a/app/Actions/Server/ResourcesCheck.php b/app/Actions/Server/ResourcesCheck.php
deleted file mode 100644
index e6b90ba38..000000000
--- a/app/Actions/Server/ResourcesCheck.php
+++ /dev/null
@@ -1,41 +0,0 @@
-subSeconds($seconds))->update(['status' => 'exited']);
- ServiceApplication::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- ServiceDatabase::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandalonePostgresql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneRedis::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMongodb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMysql::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneMariadb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneKeydb::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneDragonfly::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- StandaloneClickhouse::where('last_online_at', '<', now()->subSeconds($seconds))->update(['status' => 'exited']);
- } catch (\Throwable $e) {
- return handleError($e);
- }
- }
-}
diff --git a/app/Actions/Server/StartLogDrain.php b/app/Actions/Server/StartLogDrain.php
index e4df5a061..eb419992d 100644
--- a/app/Actions/Server/StartLogDrain.php
+++ b/app/Actions/Server/StartLogDrain.php
@@ -3,6 +3,7 @@
namespace App\Actions\Server;
use App\Models\Server;
+use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain
@@ -201,10 +202,29 @@ Files:
"echo 'Starting Fluent Bit'",
"cd $config_path && docker compose up -d",
];
+ $command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
return instant_remote_process($command, $server);
} catch (\Throwable $e) {
return handleError($e);
}
}
+
+ private function logDrainNetworkConnectCommands(Server $server): array
+ {
+ if (! $server->isLogDrainEnabled()) {
+ return [];
+ }
+
+ return $server->services()
+ ->with('destination')
+ ->where('connect_to_docker_network', true)
+ ->get()
+ ->map(fn (Service $service) => data_get($service, 'destination.network'))
+ ->filter()
+ ->unique()
+ ->map(fn (string $network) => 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true')
+ ->values()
+ ->all();
+ }
}
diff --git a/app/Actions/Server/StartSentinel.php b/app/Actions/Server/StartSentinel.php
index 071f3ec46..289ab9ebe 100644
--- a/app/Actions/Server/StartSentinel.php
+++ b/app/Actions/Server/StartSentinel.php
@@ -4,7 +4,6 @@ namespace App\Actions\Server;
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ class StartSentinel
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
- $token = data_get($server, 'settings.sentinel_token');
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
- }
+ $token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
diff --git a/app/Actions/Service/RestartService.php b/app/Actions/Service/RestartService.php
index d38ef54d6..6acd3b0a4 100644
--- a/app/Actions/Service/RestartService.php
+++ b/app/Actions/Service/RestartService.php
@@ -13,8 +13,10 @@ class RestartService
public function handle(Service $service, bool $pullLatestImages)
{
- StopService::run($service);
-
- return StartService::run($service, $pullLatestImages);
+ return StartService::run(
+ service: $service,
+ pullLatestImages: $pullLatestImages,
+ stopBeforeStart: true,
+ );
}
}
diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php
index 17948d93b..463a8ad5b 100644
--- a/app/Actions/Service/StartService.php
+++ b/app/Actions/Service/StartService.php
@@ -4,18 +4,22 @@ namespace App\Actions\Service;
use App\Models\Service;
use Lorisleiva\Actions\Concerns\AsAction;
+use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
use AsAction;
- public string $jobQueue = 'high';
+ public function configureJob(JobDecorator $job): void
+ {
+ $job->onQueue(deployment_queue());
+ }
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{
$service->parse();
- if ($stopBeforeStart) {
+ if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
StopService::run(service: $service, dockerCleanup: false);
}
$service->saveComposeConfigs();
@@ -46,7 +50,34 @@ class StartService
$commands[] = "docker network connect --alias {$serviceName}-{$service->uuid} {$safeNetwork} {$serviceName}-{$service->uuid} >/dev/null 2>&1 || true";
}
}
+ $commands = array_merge($commands, $this->logDrainNetworkConnectCommands($service));
return remote_process($commands, $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
+
+ private function logDrainNetworkConnectCommands(Service $service): array
+ {
+ if (! data_get($service, 'connect_to_docker_network')) {
+ return [];
+ }
+
+ if (! $service->destination?->server?->isLogDrainEnabled()) {
+ return [];
+ }
+
+ $network = data_get($service, 'destination.network');
+
+ if (blank($network)) {
+ return [];
+ }
+
+ return [
+ 'docker network connect '.escapeshellarg($network).' coolify-log-drain >/dev/null 2>&1 || true',
+ ];
+ }
+
+ private function shouldStopBeforeStarting(bool $pullLatestImages, bool $stopBeforeStart): bool
+ {
+ return $stopBeforeStart && ! $pullLatestImages;
+ }
}
diff --git a/app/Actions/User/DeleteUserTeams.php b/app/Actions/User/DeleteUserTeams.php
index d572db9e7..b2b06e7ba 100644
--- a/app/Actions/User/DeleteUserTeams.php
+++ b/app/Actions/User/DeleteUserTeams.php
@@ -137,9 +137,11 @@ class DeleteUserTeams
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
+ RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team
$team->members()->detach($this->user->id);
+ RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['transferred']++;
} catch (\Exception $e) {
@@ -152,6 +154,7 @@ class DeleteUserTeams
foreach ($preview['to_leave'] as $team) {
try {
$team->members()->detach($this->user->id);
+ RevokeUserTeamTokens::forUserTeam($this->user, $team->id);
$counts['left']++;
} catch (\Exception $e) {
\Log::error("Failed to remove user from team {$team->id}: ".$e->getMessage());
diff --git a/app/Actions/User/RevokeUserTeamTokens.php b/app/Actions/User/RevokeUserTeamTokens.php
new file mode 100644
index 000000000..9aadf1eeb
--- /dev/null
+++ b/app/Actions/User/RevokeUserTeamTokens.php
@@ -0,0 +1,43 @@
+where('tokenable_id', self::userId($user))
+ ->where('team_id', $teamId)
+ ->delete();
+ }
+
+ public static function forUser(User|int $user): int
+ {
+ return self::baseQuery()
+ ->where('tokenable_id', self::userId($user))
+ ->delete();
+ }
+
+ public static function forTeam(int|string $teamId): int
+ {
+ return self::baseQuery()
+ ->where('team_id', $teamId)
+ ->delete();
+ }
+
+ private static function baseQuery(): Builder
+ {
+ return PersonalAccessToken::query()
+ ->where('tokenable_type', User::class);
+ }
+
+ private static function userId(User|int $user): int
+ {
+ return $user instanceof User ? $user->id : $user;
+ }
+}
diff --git a/app/Casts/EncryptedArrayCast.php b/app/Casts/EncryptedArrayCast.php
new file mode 100644
index 000000000..4f72c6286
--- /dev/null
+++ b/app/Casts/EncryptedArrayCast.php
@@ -0,0 +1,51 @@
+|null, array|null>
+ */
+class EncryptedArrayCast implements CastsAttributes
+{
+ /**
+ * @param array $attributes
+ * @return array|null
+ */
+ public function get(Model $model, string $key, mixed $value, array $attributes): ?array
+ {
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ try {
+ $value = Crypt::decryptString($value);
+ } catch (DecryptException) {
+ // Legacy plaintext JSON written before this column was encrypted.
+ }
+
+ $decoded = json_decode((string) $value, true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ /**
+ * @param array $attributes
+ */
+ public function set(Model $model, string $key, mixed $value, array $attributes): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return Crypt::encryptString(json_encode($value, JSON_THROW_ON_ERROR));
+ }
+}
diff --git a/app/Console/Commands/CleanupUnreachableServers.php b/app/Console/Commands/CleanupUnreachableServers.php
index 09563a2c3..666e98a18 100644
--- a/app/Console/Commands/CleanupUnreachableServers.php
+++ b/app/Console/Commands/CleanupUnreachableServers.php
@@ -18,9 +18,13 @@ class CleanupUnreachableServers extends Command
if ($servers->count() > 0) {
foreach ($servers as $server) {
echo "Cleanup unreachable server ($server->id) with name $server->name";
- $server->update([
- 'ip' => '1.2.3.4',
- ]);
+ if (isCloud()) {
+ $server->update([
+ 'ip' => '1.2.3.4',
+ ]);
+ } else {
+ $server->forceDisableServer();
+ }
}
}
}
diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php
index e95c29f72..4783df072 100644
--- a/app/Console/Commands/Init.php
+++ b/app/Console/Commands/Init.php
@@ -253,7 +253,7 @@ class Init extends Command
'save_s3' => false,
'frequency' => '0 0 * * *',
'database_id' => $database->id,
- 'database_type' => \App\Models\StandalonePostgresql::class,
+ 'database_type' => StandalonePostgresql::class,
'team_id' => 0,
]);
}
diff --git a/app/Console/Commands/SyncBunny.php b/app/Console/Commands/SyncBunny.php
index 9ac3371e0..d6d77f22e 100644
--- a/app/Console/Commands/SyncBunny.php
+++ b/app/Console/Commands/SyncBunny.php
@@ -16,7 +16,7 @@ class SyncBunny extends Command
*
* @var string
*/
- protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
+ protected $signature = 'sync:bunny {--templates} {--release} {--nightly}';
/**
* The console command description.
@@ -25,650 +25,6 @@ class SyncBunny extends Command
*/
protected $description = 'Sync files to BunnyCDN';
- /**
- * Fetch GitHub releases and sync to GitHub repository
- */
- private function syncReleasesToGitHubRepo(): bool
- {
- $this->info('Fetching releases from GitHub...');
- try {
- $response = Http::timeout(30)
- ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
- 'per_page' => 30, // Fetch more releases for better changelog
- ]);
-
- if (! $response->successful()) {
- $this->error('Failed to fetch releases from GitHub: '.$response->status());
-
- return false;
- }
-
- $releases = $response->json();
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-'.$timestamp;
- $branchName = 'update-releases-'.$timestamp;
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Write releases.json
- $this->info('Writing releases.json...');
- $releasesPath = "$tmpDir/json/releases.json";
- $releasesDir = dirname($releasesPath);
-
- // Ensure directory exists
- if (! is_dir($releasesDir)) {
- $this->info("Creating directory: $releasesDir");
- if (! mkdir($releasesDir, 0755, true)) {
- $this->error("Failed to create directory: $releasesDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $bytesWritten = file_put_contents($releasesPath, $jsonContent);
-
- if ($bytesWritten === false) {
- $this->error("Failed to write releases.json to: $releasesPath");
- $this->error('Possible reasons: permission denied or disk full.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Stage and commit
- $this->info('Committing changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain json/releases.json 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('Releases are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- $commitMessage = 'Update releases.json with latest releases - '.date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
- $prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR Output: '.implode("\n", $output));
- }
- $this->info('Total releases synced: '.count($releases));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing releases: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync both releases.json and versions.json to GitHub repository in one PR
- */
- private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
- {
- $this->info('Syncing releases.json and versions.json to GitHub repository...');
- try {
- // 1. Fetch releases from GitHub API
- $this->info('Fetching releases from GitHub API...');
- $response = Http::timeout(30)
- ->get('https://api.github.com/repos/coollabsio/coolify/releases', [
- 'per_page' => 30,
- ]);
-
- if (! $response->successful()) {
- $this->error('Failed to fetch releases from GitHub: '.$response->status());
-
- return false;
- }
-
- $releases = $response->json();
-
- // 2. Read versions.json
- if (! file_exists($versionsLocation)) {
- $this->error("versions.json not found at: $versionsLocation");
-
- return false;
- }
-
- $file = file_get_contents($versionsLocation);
- $versionsJson = json_decode($file, true);
- $actualVersion = data_get($versionsJson, 'coolify.v4.version');
-
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
- $branchName = 'update-releases-and-versions-'.$timestamp;
- $versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
-
- // 3. Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // 4. Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 5. Write releases.json
- $this->info('Writing releases.json...');
- $releasesPath = "$tmpDir/json/releases.json";
- $releasesDir = dirname($releasesPath);
-
- if (! is_dir($releasesDir)) {
- if (! mkdir($releasesDir, 0755, true)) {
- $this->error("Failed to create directory: $releasesDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
- $this->error("Failed to write releases.json to: $releasesPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 6. Write versions.json
- $this->info('Writing versions.json...');
- $versionsPath = "$tmpDir/$versionsTargetPath";
- $versionsDir = dirname($versionsPath);
-
- if (! is_dir($versionsDir)) {
- if (! mkdir($versionsDir, 0755, true)) {
- $this->error("Failed to create directory: $versionsDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
- $this->error("Failed to write versions.json to: $versionsPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 7. Stage both files
- $this->info('Staging changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 8. Check for changes
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('Both files are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // 9. Commit changes
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 10. Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // 11. Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // 12. Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info("Version synced: $actualVersion");
- $this->info('Total releases synced: '.count($releases));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing to GitHub: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync install.sh, docker-compose, and env files to GitHub repository via PR
- */
- private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
- {
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("Syncing $envLabel files to GitHub repository...");
- try {
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-files-'.$timestamp;
- $branchName = 'update-files-'.$timestamp;
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Copy each file to its target path in the CDN repo
- $copiedFiles = [];
- foreach ($files as $sourceFile => $targetPath) {
- if (! file_exists($sourceFile)) {
- $this->warn("Source file not found, skipping: $sourceFile");
-
- continue;
- }
-
- $destPath = "$tmpDir/$targetPath";
- $destDir = dirname($destPath);
-
- if (! is_dir($destDir)) {
- if (! mkdir($destDir, 0755, true)) {
- $this->error("Failed to create directory: $destDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- if (copy($sourceFile, $destPath) === false) {
- $this->error("Failed to copy $sourceFile to $destPath");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $copiedFiles[] = $targetPath;
- $this->info("Copied: $targetPath");
- }
-
- if (empty($copiedFiles)) {
- $this->warn('No files were copied. Nothing to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // Stage all copied files
- $this->info('Staging changes...');
- $output = [];
- $stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
- exec($stageCmd, $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Check for changes
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('All files are already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- // Commit changes
- $commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
- $fileList = implode("\n- ", $copiedFiles);
- $prBody = "Automated update of $envLabel files:\n- $fileList";
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- $output = [];
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info('Files synced: '.count($copiedFiles));
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing files to GitHub: '.$e->getMessage());
-
- return false;
- }
- }
-
- /**
- * Sync versions.json to GitHub repository via PR
- */
- private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
- {
- $this->info('Syncing versions.json to GitHub repository...');
- try {
- if (! file_exists($versionsLocation)) {
- $this->error("versions.json not found at: $versionsLocation");
-
- return false;
- }
-
- $file = file_get_contents($versionsLocation);
- $json = json_decode($file, true);
- $actualVersion = data_get($json, 'coolify.v4.version');
-
- $timestamp = time();
- $tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
- $branchName = 'update-versions-'.$timestamp;
- $targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
-
- // Clone the repository
- $this->info('Cloning coolify-cdn repository...');
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to clone repository: '.implode("\n", $output));
-
- return false;
- }
-
- // Create feature branch
- $this->info('Creating feature branch...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to create branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Write versions.json
- $this->info('Writing versions.json...');
- $versionsPath = "$tmpDir/$targetPath";
- $versionsDir = dirname($versionsPath);
-
- // Ensure directory exists
- if (! is_dir($versionsDir)) {
- $this->info("Creating directory: $versionsDir");
- if (! mkdir($versionsDir, 0755, true)) {
- $this->error("Failed to create directory: $versionsDir");
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
- }
-
- $jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $bytesWritten = file_put_contents($versionsPath, $jsonContent);
-
- if ($bytesWritten === false) {
- $this->error("Failed to write versions.json to: $versionsPath");
- $this->error('Possible reasons: permission denied or disk full.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Stage and commit
- $this->info('Committing changes...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to stage changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- $this->info('Checking for changes...');
- $statusOutput = [];
- exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to check repository status: '.implode("\n", $statusOutput));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- if (empty(array_filter($statusOutput))) {
- $this->info('versions.json is already up to date. No changes to commit.');
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return true;
- }
-
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to commit changes: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Push to remote
- $this->info('Pushing branch to remote...');
- $output = [];
- exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
- if ($returnCode !== 0) {
- $this->error('Failed to push branch: '.implode("\n", $output));
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- return false;
- }
-
- // Create pull request
- $this->info('Creating pull request...');
- $prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
- $prBody = "Automated update of $envLabel versions.json to version $actualVersion";
- $output = [];
- $prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
- exec($prCommand, $output, $returnCode);
-
- // Clean up
- exec('rm -rf '.escapeshellarg($tmpDir));
-
- if ($returnCode !== 0) {
- $this->error('Failed to create PR: '.implode("\n", $output));
-
- return false;
- }
-
- $this->info('Pull request created successfully!');
- if (! empty($output)) {
- $this->info('PR URL: '.implode("\n", $output));
- }
- $this->info("Version synced: $actualVersion");
-
- return true;
- } catch (\Throwable $e) {
- $this->error('Error syncing versions.json: '.$e->getMessage());
-
- return false;
- }
- }
-
/**
* Execute the console command.
*/
@@ -677,8 +33,6 @@ class SyncBunny extends Command
$that = $this;
$only_template = $this->option('templates');
$only_version = $this->option('release');
- $only_github_releases = $this->option('github-releases');
- $only_github_versions = $this->option('github-versions');
$nightly = $this->option('nightly');
$bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify';
@@ -736,30 +90,11 @@ class SyncBunny extends Command
$install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions";
}
- if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
+ if (! $only_template && ! $only_version) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $this->info("About to sync $envLabel files to BunnyCDN and create a GitHub PR for coolify-cdn.");
+ $this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine();
- // Build file mapping for diff
- if ($nightly) {
- $fileMapping = [
- $compose_file_location => 'docker/nightly/docker-compose.yml',
- $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
- $production_env_location => 'environment/nightly/.env.production',
- $upgrade_script_location => 'scripts/nightly/upgrade.sh',
- $install_script_location => 'scripts/nightly/install.sh',
- ];
- } else {
- $fileMapping = [
- $compose_file_location => 'docker/docker-compose.yml',
- $compose_file_prod_location => 'docker/docker-compose.prod.yml',
- $production_env_location => 'environment/.env.production',
- $upgrade_script_location => 'scripts/upgrade.sh',
- $install_script_location => 'scripts/install.sh',
- ];
- }
-
// BunnyCDN file mapping (local file => CDN URL path)
$bunnyFileMapping = [
$compose_file_location => "$bunny_cdn/$bunny_cdn_path/$compose_file",
@@ -812,44 +147,6 @@ class SyncBunny extends Command
}
}
- // Diff against GitHub coolify-cdn repo
- $this->newLine();
- $this->info('Fetching coolify-cdn repo to compare...');
- $output = [];
- exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg("$diffTmpDir/repo").' -- --depth 1 2>&1', $output, $returnCode);
-
- if ($returnCode === 0) {
- foreach ($fileMapping as $localFile => $cdnPath) {
- $remotePath = "$diffTmpDir/repo/$cdnPath";
- if (! file_exists($localFile)) {
- continue;
- }
- if (! file_exists($remotePath)) {
- $this->info("NEW on GitHub: $cdnPath (does not exist in coolify-cdn yet)");
- $hasChanges = true;
-
- continue;
- }
-
- $diffOutput = [];
- exec('diff -u '.escapeshellarg($remotePath).' '.escapeshellarg($localFile).' 2>&1', $diffOutput, $diffCode);
- if ($diffCode !== 0) {
- $hasChanges = true;
- $this->newLine();
- $this->info("--- GitHub: $cdnPath");
- $this->info("+++ Local: $cdnPath");
- foreach ($diffOutput as $line) {
- if (str_starts_with($line, '---') || str_starts_with($line, '+++')) {
- continue;
- }
- $this->line($line);
- }
- }
- }
- } else {
- $this->warn('Could not fetch coolify-cdn repo for diff.');
- }
-
exec('rm -rf '.escapeshellarg($diffTmpDir));
if (! $hasChanges) {
@@ -881,9 +178,9 @@ class SyncBunny extends Command
return;
} elseif ($only_version) {
if ($nightly) {
- $this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
+ $this->info('About to sync NIGHTLY versions.json to BunnyCDN.');
} else {
- $this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
+ $this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
}
$file = file_get_contents($versions_location);
$json = json_decode($file, true);
@@ -891,8 +188,7 @@ class SyncBunny extends Command
$this->info("Version: {$actual_version}");
$this->info('This will:');
- $this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
- $this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
+ $this->info(' 1. Sync versions.json to BunnyCDN');
$this->newLine();
$confirmed = confirm('Are you sure you want to proceed?');
@@ -900,8 +196,7 @@ class SyncBunny extends Command
return;
}
- // 1. Sync versions.json to BunnyCDN (deprecated but still needed)
- $this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
+ $this->info('Syncing versions.json to BunnyCDN...');
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
@@ -909,46 +204,8 @@ class SyncBunny extends Command
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
$this->newLine();
- // 2. Create GitHub PR with both releases.json and versions.json
- $this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
- $githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
- if ($githubSuccess) {
- $this->info('✓ GitHub PR created successfully with both files');
- } else {
- $this->error('✗ Failed to create GitHub PR');
- }
- $this->newLine();
-
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: ✓ Complete');
- $this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
-
- return;
- } elseif ($only_github_releases) {
- $this->info('About to sync GitHub releases to GitHub repository.');
- $confirmed = confirm('Are you sure you want to sync GitHub releases?');
- if (! $confirmed) {
- return;
- }
-
- // Sync releases to GitHub repository
- $this->syncReleasesToGitHubRepo();
-
- return;
- } elseif ($only_github_versions) {
- $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
- $file = file_get_contents($versions_location);
- $json = json_decode($file, true);
- $actual_version = data_get($json, 'coolify.v4.version');
-
- $this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
- $confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
- if (! $confirmed) {
- return;
- }
-
- // Sync versions.json to GitHub repository
- $this->syncVersionsToGitHubRepo($versions_location, $nightly);
return;
}
@@ -970,31 +227,8 @@ class SyncBunny extends Command
$this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine();
- // Sync files to GitHub CDN repository via PR
- $this->info('Creating GitHub PR for coolify-cdn repository...');
- if ($nightly) {
- $files = [
- $compose_file_location => 'docker/nightly/docker-compose.yml',
- $compose_file_prod_location => 'docker/nightly/docker-compose.prod.yml',
- $production_env_location => 'environment/nightly/.env.production',
- $upgrade_script_location => 'scripts/nightly/upgrade.sh',
- $install_script_location => 'scripts/nightly/install.sh',
- ];
- } else {
- $files = [
- $compose_file_location => 'docker/docker-compose.yml',
- $compose_file_prod_location => 'docker/docker-compose.prod.yml',
- $production_env_location => 'environment/.env.production',
- $upgrade_script_location => 'scripts/upgrade.sh',
- $install_script_location => 'scripts/install.sh',
- ];
- }
-
- $githubSuccess = $this->syncFilesToGitHubRepo($files, $nightly);
- $this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete');
- $this->info('GitHub PR: '.($githubSuccess ? 'Created' : 'Failed'));
} catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage());
}
diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php
index 9ecf90716..b6e9a6121 100644
--- a/app/Console/Commands/ViewScheduledLogs.php
+++ b/app/Console/Commands/ViewScheduledLogs.php
@@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
public function handle()
{
$date = $this->option('date') ?: now()->format('Y-m-d');
+ if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
+
+ return self::INVALID;
+ }
$logPaths = $this->getLogPaths($date);
if (empty($logPaths)) {
@@ -49,17 +54,19 @@ class ViewScheduledLogs extends Command
$this->line('');
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -f {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPath}");
}
} else {
// Multiple files - use multitail or tail with process substitution
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPathsStr}");
}
@@ -68,20 +75,23 @@ class ViewScheduledLogs extends Command
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line('');
+ $escapedLines = escapeshellarg((string) $lines);
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPath}");
+ passthru("tail -n {$escapedLines} {$logPath}");
}
} else {
// Multiple files - concatenate and sort by timestamp
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPathsStr} | sort");
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
}
}
}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index c5e12b7ee..e6dc32383 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -2,11 +2,13 @@
namespace App\Console;
+use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
use App\Jobs\CleanupInstanceStuffsJob;
use App\Jobs\CleanupOrphanedPreviewContainersJob;
+use App\Jobs\CleanupStaleMultiplexedConnections;
use App\Jobs\PullChangelog;
use App\Jobs\PullTemplatesFromCDN;
use App\Jobs\RegenerateSslCertJob;
@@ -39,8 +41,13 @@ class Kernel extends ConsoleKernel
$this->instanceTimezone = config('app.timezone');
}
- // $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
+ $this->scheduleInstance->call(fn () => app(CleanupStaleMultiplexedConnections::class)->handle())
+ ->name('cleanup:ssh-mux')
+ ->hourly()
+ ->when(fn () => config('constants.ssh.mux_enabled') && ! config('constants.coolify.is_windows_docker_desktop'));
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
+ $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
+ $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs
@@ -75,7 +82,7 @@ class Kernel extends ConsoleKernel
// Scheduled Jobs (Backups & Tasks)
$this->scheduleInstance->job(new ScheduledJobManager)->everyMinute()->onOneServer();
- $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily();
+ $this->scheduleInstance->job(new RegenerateSslCertJob)->twiceDaily()->onOneServer();
$this->scheduleInstance->job(new CheckTraefikVersionJob)->weekly()->sundays()->at('00:00')->timezone($this->instanceTimezone)->onOneServer();
diff --git a/app/Enums/BuildPackTypes.php b/app/Enums/BuildPackTypes.php
index cb51db6d6..eee898823 100644
--- a/app/Enums/BuildPackTypes.php
+++ b/app/Enums/BuildPackTypes.php
@@ -8,4 +8,5 @@ enum BuildPackTypes: string
case STATIC = 'static';
case DOCKERFILE = 'dockerfile';
case DOCKERCOMPOSE = 'dockercompose';
+ case RAILPACK = 'railpack';
}
diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php
index 71de48bcd..58f21c793 100644
--- a/app/Exceptions/Handler.php
+++ b/app/Exceptions/Handler.php
@@ -4,8 +4,10 @@ namespace App\Exceptions;
use App\Models\InstanceSettings;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
- * @var array, \Psr\Log\LogLevel::*>
+ * @var array, LogLevel::*>
*/
protected $levels = [
//
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
- * @var array>
+ * @var array>
*/
protected $dontReport = [
ProcessException::class,
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $exception->getMessage(),
+ 'guards' => $exception->guards(),
+ ], 'warning');
+ }
+
return response()->json(['message' => $exception->getMessage()], 401);
}
@@ -61,8 +70,15 @@ class Handler extends ExceptionHandler
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.policy_denied', [
+ 'reason' => $e->getMessage(),
+ 'route' => $request->route()?->getName() ?? $request->path(),
+ ], 'warning');
+ }
+
// Get the custom message from the policy if available
$message = $e->getMessage();
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index aa9d06996..907cb4456 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -4,6 +4,7 @@ namespace App\Helpers;
use App\Models\PrivateKey;
use App\Models\Server;
+use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
@@ -12,15 +13,13 @@ use Illuminate\Support\Facades\Storage;
class SshMultiplexingHelper
{
- public static function serverSshConfiguration(Server $server)
+ public static function serverSshConfiguration(Server $server): array
{
$privateKey = PrivateKey::findOrFail($server->private_key_id);
- $sshKeyLocation = $privateKey->getKeyLocation();
- $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
return [
- 'sshKeyLocation' => $sshKeyLocation,
- 'muxFilename' => $muxFilename,
+ 'sshKeyLocation' => $privateKey->getKeyLocation(),
+ 'muxFilename' => self::muxSocket($server),
];
}
@@ -30,40 +29,39 @@ class SshMultiplexingHelper
return false;
}
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
-
- // Check if connection exists
- $checkCommand = "ssh -O check -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $checkCommand .= self::escapedUserAtHost($server);
- $process = Process::run($checkCommand);
-
- if ($process->exitCode() !== 0) {
- return self::establishNewMultiplexedConnection($server);
+ if (self::connectionIsReusable($server)) {
+ return true;
}
- // Connection exists, ensure we have metadata for age tracking
- if (self::getConnectionAge($server) === null) {
- // Existing connection but no metadata, store current time as fallback
- self::storeConnectionMetadata($server);
- }
+ try {
+ return Cache::lock(
+ self::connectionLockKey($server),
+ config('constants.ssh.mux_lock_ttl')
+ )->block(config('constants.ssh.mux_lock_timeout'), function () use ($server) {
+ if (self::connectionIsReusable($server)) {
+ return true;
+ }
- // Connection exists, check if it needs refresh due to age
- if (self::isConnectionExpired($server)) {
- return self::refreshMultiplexedConnection($server);
- }
+ if (self::masterConnectionExists($server)) {
+ return self::refreshMultiplexedConnection($server);
+ }
- // Perform health check if enabled
- if (config('constants.ssh.mux_health_check_enabled')) {
- if (! self::isConnectionHealthy($server)) {
- return self::refreshMultiplexedConnection($server);
- }
- }
+ return self::establishNewMultiplexedConnection($server);
+ });
+ } catch (LockTimeoutException) {
+ Log::warning('SSH multiplexing lock timeout, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ ]);
- return true;
+ return false;
+ } catch (\Throwable $e) {
+ Log::warning('SSH multiplexing lock unavailable, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
}
public static function establishNewMultiplexedConnection(Server $server): bool
@@ -71,86 +69,72 @@ class SshMultiplexingHelper
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
- $connectionTimeout = config('constants.ssh.connection_timeout');
+ $connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
- $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ $establishCommand = "ssh -fN -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
$establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
+
$establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval);
$establishCommand .= self::escapedUserAtHost($server);
+
$establishProcess = Process::run($establishCommand);
if ($establishProcess->exitCode() !== 0) {
return false;
}
- // Store connection metadata for tracking
self::storeConnectionMetadata($server);
return true;
}
- public static function removeMuxFile(Server $server)
+ public static function removeMuxFile(Server $server): void
{
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
-
- $closeCommand = "ssh -O exit -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $closeCommand .= self::escapedUserAtHost($server);
- Process::run($closeCommand);
-
- // Clear connection metadata from cache
+ Process::run(self::muxControlCommand($server, 'exit'));
self::clearConnectionMetadata($server);
}
- public static function generateScpCommand(Server $server, string $source, string $dest)
+ public static function generateScpCommand(Server $server, string $source, string $dest): string
{
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
- $muxSocket = $sshConfig['muxFilename'];
+ $scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
- $timeout = config('constants.ssh.command_timeout');
- $muxPersistTime = config('constants.ssh.mux_persist_time');
-
- $scp_command = "timeout $timeout scp ";
if ($server->isIpv6()) {
- $scp_command .= '-6 ';
+ $scpCommand .= '-6 ';
}
+
if (self::isMultiplexingEnabled()) {
try {
if (self::ensureMultiplexedConnection($server)) {
- $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ $scpCommand .= self::multiplexingOptions($server);
}
- } catch (\Exception $e) {
+ } catch (\Throwable $e) {
Log::warning('SSH multiplexing failed for SCP, falling back to non-multiplexed connection', [
'server' => $server->name ?? $server->ip,
'error' => $e->getMessage(),
]);
- // Continue without multiplexing
}
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ $scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
- $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
+ $scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
+
if ($server->isIpv6()) {
- $scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
- } else {
- $scp_command .= "{$source} ".self::escapedUserAtHost($server).":{$dest}";
+ return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
}
- return $scp_command;
+ return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
}
- public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
+ public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
{
if ($server->settings->force_disabled) {
throw new \RuntimeException('Server is disabled.');
@@ -161,40 +145,139 @@ class SshMultiplexingHelper
self::validateSshKey($server->privateKey);
- $muxSocket = $sshConfig['muxFilename'];
+ $commandTimeout = $commandTimeout ?? (int) config('constants.ssh.command_timeout');
+ $sshCommand = $commandTimeout > 0 ? "timeout {$commandTimeout} ssh " : 'ssh ';
- $timeout = config('constants.ssh.command_timeout');
- $muxPersistTime = config('constants.ssh.mux_persist_time');
-
- $ssh_command = "timeout $timeout ssh ";
-
- $multiplexingSuccessful = false;
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
try {
- $multiplexingSuccessful = self::ensureMultiplexedConnection($server);
- if ($multiplexingSuccessful) {
- $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} ";
+ if (self::ensureMultiplexedConnection($server)) {
+ $sshCommand .= self::multiplexingOptions($server);
}
- } catch (\Exception $e) {
- // Continue without multiplexing
+ } catch (\Throwable $e) {
+ Log::warning('SSH multiplexing failed, falling back to non-multiplexed connection', [
+ 'server' => $server->name ?? $server->ip,
+ 'error' => $e->getMessage(),
+ ]);
}
}
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
+ $sshCommand .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
- $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
+ $sshCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
- $delimiter = Hash::make($command);
- $delimiter = base64_encode($delimiter);
+ $delimiter = base64_encode(Hash::make($command));
$command = str_replace($delimiter, '', $command);
- $ssh_command .= self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
+ return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
.$command.PHP_EOL
.$delimiter;
+ }
- return $ssh_command;
+ public static function getConnectionTimeout(Server $server): int
+ {
+ $timeout = data_get($server, 'settings.connection_timeout');
+
+ return is_numeric($timeout) && (int) $timeout > 0
+ ? (int) $timeout
+ : (int) config('constants.ssh.connection_timeout');
+ }
+
+ public static function isConnectionHealthy(Server $server): bool
+ {
+ $sshConfig = self::serverSshConfiguration($server);
+ $muxSocket = $sshConfig['muxFilename'];
+ $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
+
+ $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
+ if (data_get($server, 'settings.is_cloudflare_tunnel')) {
+ $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ }
+ $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
+
+ $process = Process::run($healthCommand);
+
+ return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
+ }
+
+ public static function isConnectionExpired(Server $server): bool
+ {
+ $connectionAge = self::getConnectionAge($server);
+ $maxAge = config('constants.ssh.mux_max_age');
+
+ return $connectionAge !== null && $connectionAge > $maxAge;
+ }
+
+ public static function getConnectionAge(Server $server): ?int
+ {
+ $connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
+
+ if ($connectionTime === null) {
+ return null;
+ }
+
+ return time() - $connectionTime;
+ }
+
+ public static function refreshMultiplexedConnection(Server $server): bool
+ {
+ self::removeMuxFile($server);
+
+ return self::establishNewMultiplexedConnection($server);
+ }
+
+ private static function connectionLockKey(Server $server): string
+ {
+ return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
+ }
+
+ private static function masterConnectionExists(Server $server): bool
+ {
+ return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
+ }
+
+ private static function connectionIsReusable(Server $server): bool
+ {
+ if (! self::masterConnectionExists($server)) {
+ return false;
+ }
+
+ if (self::getConnectionAge($server) === null) {
+ self::storeConnectionMetadata($server);
+ }
+
+ if (self::isConnectionExpired($server)) {
+ return false;
+ }
+
+ if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
+ return false;
+ }
+
+ return true;
+ }
+
+ private static function muxControlCommand(Server $server, string $operation): string
+ {
+ $command = "ssh -O {$operation} -o ControlPath=".self::muxSocket($server).' ';
+ if (data_get($server, 'settings.is_cloudflare_tunnel')) {
+ $command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
+ }
+
+ return $command.self::escapedUserAtHost($server);
+ }
+
+ private static function multiplexingOptions(Server $server): string
+ {
+ return '-o ControlMaster=auto '
+ .'-o ControlPath='.self::muxSocket($server).' '
+ .'-o ControlPersist='.config('constants.ssh.mux_persist_time').' ';
+ }
+
+ private static function muxSocket(Server $server): string
+ {
+ return '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid;
}
private static function escapedUserAtHost(Server $server): string
@@ -231,7 +314,6 @@ class SshMultiplexingHelper
$privateKey->storeInFileSystem();
}
- // Ensure correct permissions (SSH requires 0600)
if (file_exists($keyLocation)) {
$currentPerms = fileperms($keyLocation) & 0777;
if ($currentPerms !== 0600 && ! chmod($keyLocation, 0600)) {
@@ -253,90 +335,20 @@ class SshMultiplexingHelper
.'-o RequestTTY=no '
.'-o LogLevel=ERROR ';
- // Bruh
if ($isScp) {
- $options .= '-P '.escapeshellarg((string) $server->port).' ';
- } else {
- $options .= '-p '.escapeshellarg((string) $server->port).' ';
+ return $options.'-P '.escapeshellarg((string) $server->port).' ';
}
- return $options;
+ return $options.'-p '.escapeshellarg((string) $server->port).' ';
}
- /**
- * Check if the multiplexed connection is healthy by running a test command
- */
- public static function isConnectionHealthy(Server $server): bool
- {
- $sshConfig = self::serverSshConfiguration($server);
- $muxSocket = $sshConfig['muxFilename'];
- $healthCheckTimeout = config('constants.ssh.mux_health_check_timeout');
-
- $healthCommand = "timeout $healthCheckTimeout ssh -o ControlMaster=auto -o ControlPath=$muxSocket ";
- if (data_get($server, 'settings.is_cloudflare_tunnel')) {
- $healthCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
- }
- $healthCommand .= self::escapedUserAtHost($server)." 'echo \"health_check_ok\"'";
-
- $process = Process::run($healthCommand);
- $isHealthy = $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
-
- return $isHealthy;
- }
-
- /**
- * Check if the connection has exceeded its maximum age
- */
- public static function isConnectionExpired(Server $server): bool
- {
- $connectionAge = self::getConnectionAge($server);
- $maxAge = config('constants.ssh.mux_max_age');
-
- return $connectionAge !== null && $connectionAge > $maxAge;
- }
-
- /**
- * Get the age of the current connection in seconds
- */
- public static function getConnectionAge(Server $server): ?int
- {
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- $connectionTime = Cache::get($cacheKey);
-
- if ($connectionTime === null) {
- return null;
- }
-
- return time() - $connectionTime;
- }
-
- /**
- * Refresh a multiplexed connection by closing and re-establishing it
- */
- public static function refreshMultiplexedConnection(Server $server): bool
- {
- // Close existing connection
- self::removeMuxFile($server);
-
- // Establish new connection
- return self::establishNewMultiplexedConnection($server);
- }
-
- /**
- * Store connection metadata when a new connection is established
- */
private static function storeConnectionMetadata(Server $server): void
{
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- Cache::put($cacheKey, time(), config('constants.ssh.mux_persist_time') + 300); // Cache slightly longer than persist time
+ Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
}
- /**
- * Clear connection metadata from cache
- */
private static function clearConnectionMetadata(Server $server): void
{
- $cacheKey = "ssh_mux_connection_time_{$server->uuid}";
- Cache::forget($cacheKey);
+ Cache::forget("ssh_mux_connection_time_{$server->uuid}");
}
}
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 3d92300f1..5e5405a7a 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2,13 +2,14 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
-use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
+use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -16,7 +17,7 @@ use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
-use App\Models\Service;
+use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
@@ -145,7 +146,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
+ required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -153,7 +154,7 @@ class ApplicationsController extends Controller
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -311,7 +312,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
+ required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -322,7 +323,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -477,7 +478,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
+ required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -488,7 +489,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -650,7 +651,7 @@ class ApplicationsController extends Controller
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -780,7 +781,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'],
+ required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -897,105 +898,6 @@ class ApplicationsController extends Controller
return $this->create_application($request, 'dockerimage');
}
- /**
- * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
- */
- #[OA\Post(
- summary: 'Create (Docker Compose)',
- description: 'Deprecated: Use POST /api/v1/services instead.',
- path: '/applications/dockercompose',
- operationId: 'create-dockercompose-application',
- deprecated: true,
- security: [
- ['bearerAuth' => []],
- ],
- tags: ['Applications'],
- requestBody: new OA\RequestBody(
- description: 'Application object that needs to be created.',
- required: true,
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
- properties: [
- 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
- 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
- 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
- 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
- 'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
- 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
- 'name' => ['type' => 'string', 'description' => 'The application name.'],
- 'description' => ['type' => 'string', 'description' => 'The application description.'],
- 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
- 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
- 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
- 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
- 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
- ],
- )
- ),
- ]
- ),
- responses: [
- new OA\Response(
- response: 201,
- description: 'Application created successfully.',
- content: new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'uuid' => ['type' => 'string'],
- ]
- )
- )
- ),
- new OA\Response(
- response: 401,
- ref: '#/components/responses/401',
- ),
- new OA\Response(
- response: 400,
- ref: '#/components/responses/400',
- ),
- new OA\Response(
- response: 409,
- description: 'Domain conflicts detected.',
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
- 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
- 'conflicts' => [
- 'type' => 'array',
- 'items' => new OA\Schema(
- type: 'object',
- properties: [
- 'domain' => ['type' => 'string', 'example' => 'example.com'],
- 'resource_name' => ['type' => 'string', 'example' => 'My Application'],
- 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
- 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
- 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
- ]
- ),
- ],
- ]
- )
- ),
- ]
- ),
- ]
- )]
- public function create_dockercompose_application(Request $request)
- {
- return $this->create_application($request, 'dockercompose');
- }
-
private function create_application(Request $request, $type)
{
$teamId = getTeamIdFromToken();
@@ -1058,7 +960,7 @@ class ApplicationsController extends Controller
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
- $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -1078,6 +980,9 @@ class ApplicationsController extends Controller
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@@ -1119,7 +1024,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
- 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
+ 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1307,6 +1212,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1316,7 +1230,7 @@ class ApplicationsController extends Controller
'git_repository' => 'string|required',
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
- 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
+ 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1537,6 +1451,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1547,7 +1470,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
- 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
+ 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1737,6 +1660,15 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1844,15 +1776,24 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
- 'docker_registry_image_name' => 'string|required',
- 'docker_registry_image_tag' => 'string',
- 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
+ 'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
+ 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
+ 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
@@ -1954,93 +1895,19 @@ class ApplicationsController extends Controller
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
- } elseif ($type === 'dockercompose') {
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
-
- $extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
- if (! empty($extraFields)) {
- foreach ($extraFields as $field) {
- $errors->add($field, 'This field is not allowed.');
- }
- }
-
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $errors,
- ], 422);
- }
- if (! $request->has('name')) {
- $request->offsetSet('name', 'service'.new Cuid2);
- }
- $validationRules = [
- 'docker_compose_raw' => 'string|required',
- ];
- $validationRules = array_merge(sharedDataApplications(), $validationRules);
- $validator = customApiValidator($request->all(), $validationRules);
-
- if ($validator->fails()) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $validator->errors(),
- ], 422);
- }
- $return = $this->validateDataApplications($request, $server);
- if ($return instanceof 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);
@@ -2295,6 +2162,12 @@ class ApplicationsController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.application.deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
return response()->json([
'message' => 'Application deletion request queued.',
]);
@@ -2337,7 +2210,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -2528,7 +2401,7 @@ class ApplicationsController extends Controller
}
}
}
- if ($request->has('custom_nginx_configuration')) {
+ if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
if (! isBase64Encoded($request->custom_nginx_configuration)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2546,6 +2419,9 @@ class ApplicationsController extends Controller
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -2794,6 +2670,13 @@ class ApplicationsController extends Controller
}
$application->save();
+ auditLog('api.application.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@@ -3046,6 +2929,14 @@ class ApplicationsController extends Controller
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3079,6 +2970,14 @@ class ApplicationsController extends Controller
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3305,6 +3204,12 @@ class ApplicationsController extends Controller
$returnedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.application.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_count' => $returnedEnvs->count(),
+ ]);
+
return response()->json($returnedEnvs)->setStatusCode(201);
}
@@ -3444,6 +3349,14 @@ class ApplicationsController extends Controller
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3469,6 +3382,14 @@ class ApplicationsController extends Controller
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3560,8 +3481,17 @@ class ApplicationsController extends Controller
'message' => 'Environment variable not found.',
], 404);
}
+ $envKey = $found_env->key;
+ $envUuid = $found_env->uuid;
$found_env->forceDelete();
+ auditLog('api.application.env_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json([
'message' => 'Environment variable deleted.',
]);
@@ -3673,6 +3603,15 @@ class ApplicationsController extends Controller
);
}
+ auditLog('api.application.deployed', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'force_rebuild' => $force,
+ 'instant_deploy' => $instant_deploy,
+ ]);
+
return response()->json(
[
'message' => 'Deployment request queued.',
@@ -3761,6 +3700,13 @@ class ApplicationsController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
+ auditLog('api.application.stopped', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Application stopping request queued.',
@@ -3851,6 +3797,13 @@ class ApplicationsController extends Controller
], 200);
}
+ auditLog('api.application.restarted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ ]);
+
return response()->json(
[
'message' => 'Restart request queued.',
@@ -4119,7 +4072,7 @@ class ApplicationsController extends Controller
'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',
]);
@@ -4219,6 +4172,15 @@ class ApplicationsController extends Controller
$storage->save();
+ auditLog('api.application.storage_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -4297,7 +4259,7 @@ class ApplicationsController extends Controller
'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',
@@ -4397,6 +4359,15 @@ class ApplicationsController extends Controller
]);
}
+ auditLog('api.application.storage_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -4470,8 +4441,93 @@ class ApplicationsController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.application.storage_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
+
+ #[OA\Delete(
+ summary: 'Delete Preview Deployment',
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
+ path: '/applications/{uuid}/previews/{pull_request_id}',
+ operationId: 'delete-preview-deployment-by-pull-request-id',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'pull_request_id',
+ in: 'path',
+ description: 'Pull request ID of the preview to delete.',
+ required: true,
+ schema: new OA\Schema(type: 'integer')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_preview_by_pull_request_id(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('delete', $application);
+
+ $pullRequestIdRaw = $request->route('pull_request_id');
+ if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
+ return response()->json(['message' => 'Invalid pull_request_id.'], 422);
+ }
+ $pullRequestId = (int) $pullRequestIdRaw;
+
+ $preview = ApplicationPreview::where('application_id', $application->id)
+ ->where('pull_request_id', $pullRequestId)
+ ->first();
+
+ if (! $preview) {
+ return response()->json(['message' => 'Preview not found.'], 404);
+ }
+
+ $preview->delete();
+ CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+
+ auditLog('api.application.preview_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'pull_request_id' => $pullRequestId,
+ ]);
+
+ return response()->json(['message' => 'Preview deletion request queued.']);
+ }
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..d652f2ba1 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -244,7 +245,7 @@ class CloudProviderTokensController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -286,6 +287,13 @@ class CloudProviderTokensController extends Controller
'name' => $body['name'],
]);
+ auditLog('api.cloud_token.created', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudProviderToken->uuid,
+ 'cloud_token_name' => $cloudProviderToken->name,
+ 'provider' => $cloudProviderToken->provider,
+ ]);
+
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@@ -355,7 +363,7 @@ class CloudProviderTokensController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -389,6 +397,14 @@ class CloudProviderTokensController extends Controller
$token->update(array_intersect_key($body, array_flip($allowedFields)));
+ auditLog('api.cloud_token.updated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
+ ]);
+
return response()->json([
'uuid' => $token->uuid,
]);
@@ -464,8 +480,18 @@ class CloudProviderTokensController extends Controller
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
+ auditLog('api.cloud_token.deleted', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
return response()->json(['message' => 'Cloud provider token deleted.']);
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 8e31a7051..bceef4d39 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -299,6 +299,11 @@ class DatabasesController extends Controller
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
+ 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
+ 'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
+ 'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
+ 'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
+ 'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
],
),
)
@@ -379,9 +384,9 @@ class DatabasesController extends Controller
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 +415,20 @@ class DatabasesController extends Controller
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 +455,7 @@ class DatabasesController extends Controller
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 +483,10 @@ class DatabasesController extends Controller
$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 +513,9 @@ class DatabasesController extends Controller
$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 +542,10 @@ class DatabasesController extends Controller
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')) {
@@ -565,9 +570,17 @@ class DatabasesController extends Controller
}
break;
}
+ $allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
+ $healthCheckValidator = customApiValidator($request->all(), [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer|min:1',
+ 'health_check_timeout' => 'integer|min:1',
+ 'health_check_retries' => 'integer|min:1',
+ 'health_check_start_period' => 'integer|min:0',
+ ]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
+ if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
+ $errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
@@ -596,6 +609,14 @@ class DatabasesController extends Controller
StopDatabaseProxy::dispatch($database);
}
+ auditLog('api.database.updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'message' => 'Database updated.',
]);
@@ -639,10 +660,10 @@ class DatabasesController extends Controller
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +724,10 @@ class DatabasesController extends Controller
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -747,7 +768,7 @@ class DatabasesController extends Controller
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -774,7 +795,7 @@ class DatabasesController extends Controller
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -826,6 +847,15 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'frequency' => $backupConfig->frequency,
+ 'save_s3' => (bool) $backupConfig->save_s3,
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -878,10 +908,10 @@ class DatabasesController extends Controller
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +963,10 @@ class DatabasesController extends Controller
'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()) {
@@ -982,7 +1012,7 @@ class DatabasesController extends Controller
], 422);
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -1015,7 +1045,7 @@ class DatabasesController extends Controller
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -1045,6 +1075,14 @@ class DatabasesController extends Controller
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_updated', [
+ 'team_id' => $teamId,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'database_id' => $backupConfig->database_id,
+ 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1724,9 +1762,9 @@ class DatabasesController extends Controller
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',
@@ -1766,7 +1804,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('postgres_conf', $postgresConf);
}
- $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1779,12 +1817,25 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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)) {
@@ -1821,7 +1872,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mariadb_conf', $mariadbConf);
}
- $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1835,14 +1886,24 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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);
@@ -1880,7 +1941,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mysql_conf', $mysqlConf);
}
- $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1894,11 +1955,21 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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);
@@ -1936,7 +2007,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('redis_conf', $redisConf);
}
- $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1950,11 +2021,21 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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);
@@ -1973,7 +2054,7 @@ class DatabasesController extends Controller
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1984,7 +2065,7 @@ class DatabasesController extends Controller
} 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);
@@ -2022,7 +2103,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('keydb_conf', $keydbConf);
}
- $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2036,12 +2117,22 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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)) {
@@ -2058,7 +2149,7 @@ class DatabasesController extends Controller
], 422);
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2072,14 +2163,24 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', '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)) {
@@ -2116,7 +2217,7 @@ class DatabasesController extends Controller
}
$request->offsetSet('mongo_conf', $mongoConf);
}
- $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2130,6 +2231,16 @@ class DatabasesController extends Controller
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2214,6 +2325,13 @@ class DatabasesController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.database.deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2326,13 +2444,21 @@ class DatabasesController extends Controller
$backup->delete();
DB::commit();
+ auditLog('api.database.backup_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'delete_s3' => $deleteS3,
+ 'executions_deleted' => $executions->count(),
+ ]);
+
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
} catch (\Exception $e) {
DB::rollBack();
- return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup.'], 500);
}
}
@@ -2448,11 +2574,19 @@ class DatabasesController extends Controller
$execution->delete();
+ auditLog('api.database.backup_execution_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'execution_uuid' => $request->execution_uuid,
+ 'delete_s3' => $deleteS3,
+ ]);
+
return response()->json([
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
- return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup execution.'], 500);
}
}
@@ -2630,6 +2764,13 @@ class DatabasesController extends Controller
}
StartDatabase::dispatch($database);
+ auditLog('api.database.started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2721,6 +2862,14 @@ class DatabasesController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
+ auditLog('api.database.stopped', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2798,6 +2947,13 @@ class DatabasesController extends Controller
RestartDatabase::dispatch($database);
+ auditLog('api.database.restarted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -3014,6 +3170,13 @@ class DatabasesController extends Controller
}
$env->save();
+ auditLog('api.database.env_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3142,6 +3305,12 @@ class DatabasesController extends Controller
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
+ auditLog('api.database.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3263,6 +3432,13 @@ class DatabasesController extends Controller
'comment' => $request->comment ?? null,
]);
+ auditLog('api.database.env_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3348,8 +3524,17 @@ class DatabasesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.database.env_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3496,7 +3681,7 @@ class DatabasesController extends Controller
'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 +3781,15 @@ class DatabasesController extends Controller
]);
}
+ auditLog('api.database.storage_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -3694,7 +3888,7 @@ class DatabasesController extends Controller
'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 +3988,15 @@ class DatabasesController extends Controller
$storage->save();
+ auditLog('api.database.storage_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -3867,8 +4070,18 @@ class DatabasesController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.database.storage_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 6ff06c10a..c93731d68 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -281,6 +281,14 @@ class DeployController extends Controller
}
}
+ auditLog('api.deployment.cancelled', [
+ 'team_id' => $teamId,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'application_id' => $application?->id,
+ 'application_uuid' => $application?->uuid,
+ 'server_id' => $deployment->server_id,
+ ]);
+
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@@ -518,6 +526,14 @@ class DeployController extends Controller
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
+ auditLog('api.deployment.triggered', [
+ 'resource_type' => 'application',
+ 'application_uuid' => $resource->uuid,
+ 'application_name' => $resource->name,
+ 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'force_rebuild' => $force,
+ 'pull_request_id' => $pr,
+ ]);
}
break;
case Service::class:
@@ -529,6 +545,10 @@ class DeployController extends Controller
}
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 @@ class DeployController extends Controller
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 9a2cf2b9f..651969b97 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -271,6 +271,12 @@ class GithubController extends Controller
$githubApp = GithubApp::create($payload);
+ auditLog('api.github_app.created', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ ]);
+
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@@ -650,6 +656,13 @@ class GithubController extends Controller
// Update the GitHub app
$githubApp->update($payload);
+ auditLog('api.github_app.updated', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
+ ]);
+
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@@ -734,8 +747,16 @@ class GithubController extends Controller
], 409);
}
+ $deletedUuid = $githubApp->uuid;
+ $deletedName = $githubApp->name;
$githubApp->delete();
+ auditLog('api.github_app.deleted', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $deletedUuid,
+ 'github_app_name' => $deletedName,
+ ]);
+
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index ed91b4475..2f35ba576 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@@ -12,6 +13,7 @@ use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -121,7 +123,7 @@ class HetznerController extends Controller
return response()->json($locations);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
}
}
@@ -242,7 +244,7 @@ class HetznerController extends Controller
return response()->json($serverTypes);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
}
}
@@ -354,7 +356,7 @@ class HetznerController extends Controller
return response()->json(array_values($filtered));
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
}
}
@@ -450,7 +452,7 @@ class HetznerController extends Controller
return response()->json($sshKeys);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
}
}
@@ -550,7 +552,7 @@ class HetznerController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -717,9 +719,17 @@ class HetznerController extends Controller
// Validate server if requested
if ($request->instant_validate) {
- \App\Actions\Server\ValidateServer::dispatch($server);
+ ValidateServer::dispatch($server);
}
+ auditLog('api.hetzner_server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
@@ -733,7 +743,7 @@ class HetznerController extends Controller
return $response;
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
}
}
}
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 8f2ba25c8..f17a4e46b 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ class OtherController extends Controller
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
+ auditLog('api.instance.enabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API enabled.'], 200);
}
@@ -137,21 +141,141 @@ class OtherController extends Controller
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
+ auditLog('api.instance.disabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API disabled.'], 200);
}
+ #[OA\Post(
+ summary: 'Enable MCP Server',
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/enable',
+ operationId: 'enable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server enabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to enable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function enable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => true]);
+
+ auditLog('api.mcp.enabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server enabled.'], 200);
+ }
+
+ #[OA\Post(
+ summary: 'Disable MCP Server',
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/disable',
+ operationId: 'disable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server disabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to disable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function disable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => false]);
+
+ auditLog('api.mcp.disabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server disabled.'], 200);
+ }
+
public function feedback(Request $request)
{
- $content = $request->input('content');
+ $data = $request->validate([
+ 'content' => ['required', 'string', 'min:10', 'max:2000'],
+ ]);
+
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
- Http::post($webhook_url, [
- 'content' => $content,
+ Http::timeout(5)->post($webhook_url, [
+ 'content' => $data['content'],
+ 'allowed_mentions' => ['parse' => []],
]);
}
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index ec2e300ff..0e5f6e93b 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -264,6 +264,12 @@ class ProjectController extends Controller
'team_id' => $teamId,
]);
+ auditLog('api.project.created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -382,6 +388,13 @@ class ProjectController extends Controller
$project->update($request->only($allowedFields));
+ auditLog('api.project.updated', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -460,8 +473,16 @@ class ProjectController extends Controller
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
+ $projectUuid = $project->uuid;
+ $projectName = $project->name;
$project->delete();
+ auditLog('api.project.deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $projectUuid,
+ 'project_name' => $projectName,
+ ]);
+
return response()->json(['message' => 'Project deleted.']);
}
@@ -641,6 +662,13 @@ class ProjectController extends Controller
'name' => $request->name,
]);
+ auditLog('api.project.environment_created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'environment_name' => $environment->name,
+ ]);
+
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@@ -723,8 +751,17 @@ class ProjectController extends Controller
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
+ $envUuid = $environment->uuid;
+ $envName = $environment->name;
$environment->delete();
+ auditLog('api.project.environment_deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $envUuid,
+ 'environment_name' => $envName,
+ ]);
+
return response()->json(['message' => 'Environment deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
index 6245dc2ec..d7b109918 100644
--- a/app/Http/Controllers/Api/ScheduledTasksController.php
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -33,7 +34,7 @@ class ScheduledTasksController extends Controller
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ class ScheduledTasksController extends Controller
return response()->json($tasks);
}
- private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -105,15 +106,23 @@ class ScheduledTasksController extends Controller
$task->save();
+ auditLog('api.scheduled_task.created', [
+ 'team_id' => $teamId,
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json($this->removeSensitiveData($task), 201);
}
- private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -161,22 +170,43 @@ class ScheduledTasksController extends Controller
$task->update($request->only($allowedFields));
+ auditLog('api.scheduled_task.updated', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json($this->removeSensitiveData($task), 200);
}
- private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
- $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
- if (! $deleted) {
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
+ $taskUuid = $task->uuid;
+ $taskName = $task->name;
+ $task->delete();
+
+ auditLog('api.scheduled_task.deleted', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $taskUuid,
+ 'task_name' => $taskName,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json(['message' => 'Scheduled task deleted.']);
}
- private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -238,7 +268,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,7 +347,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -404,7 +434,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -474,7 +504,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -542,7 +572,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -601,7 +631,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -680,7 +710,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -767,7 +797,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -837,7 +867,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -905,7 +935,7 @@ class ScheduledTasksController extends Controller
),
]
)]
- public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index 2c62928c2..e59c40866 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -232,6 +232,13 @@ class SecurityController extends Controller
'private_key' => $request->private_key,
]);
+ auditLog('api.private_key.created', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $key->uuid,
+ 'private_key_name' => $key->name,
+ 'fingerprint' => $fingerPrint,
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@@ -333,6 +340,13 @@ class SecurityController extends Controller
}
$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);
@@ -415,8 +429,16 @@ class SecurityController extends Controller
], 422);
}
+ $keyUuid = $key->uuid;
+ $keyName = $key->name;
$key->forceDelete();
+ auditLog('api.private_key.deleted', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $keyUuid,
+ 'private_key_name' => $keyName,
+ ]);
+
return response()->json([
'message' => 'Private Key deleted.',
]);
diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php
new file mode 100644
index 000000000..df5c60d40
--- /dev/null
+++ b/app/Http/Controllers/Api/SentinelController.php
@@ -0,0 +1,167 @@
+header('Authorization');
+ if (! $token) {
+ auditLogWebhookFailure('sentinel', 'token_missing');
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+ $naked_token = str_replace('Bearer ', '', $token);
+ try {
+ $decrypted = decrypt($naked_token);
+ $decrypted_token = json_decode($decrypted, true);
+ } catch (Exception $e) {
+ auditLogWebhookFailure('sentinel', 'decrypt_failed');
+
+ return response()->json(['message' => 'Invalid token'], 401);
+ }
+ $server_uuid = data_get($decrypted_token, 'server_uuid');
+ if (! $server_uuid) {
+ auditLogWebhookFailure('sentinel', 'invalid_token_payload');
+
+ return response()->json(['message' => 'Invalid token'], 401);
+ }
+ $server = Server::where('uuid', $server_uuid)->first();
+ if (! $server) {
+ auditLogWebhookFailure('sentinel', 'server_not_found', [
+ 'server_uuid' => $server_uuid,
+ ]);
+
+ return response()->json(['message' => 'Server not found'], 404);
+ }
+
+ if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
+ auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+
+ if ($server->isFunctional() === false) {
+ auditLogWebhookFailure('sentinel', 'server_not_functional', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Server is not functional'], 401);
+ }
+
+ if ($server->settings->sentinel_token !== $naked_token) {
+ auditLogWebhookFailure('sentinel', 'token_mismatch', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'Unauthorized'], 401);
+ }
+ $validator = Validator::make($request->all(), [
+ 'containers' => ['present', 'array'],
+ ]);
+
+ if ($validator->fails()) {
+ return response()->json(serializeApiResponse([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ]), 422);
+ }
+
+ $data = $request->all();
+
+ // Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
+ $server->sentinelHeartbeat();
+
+ if ($this->shouldDispatchUpdate($server, $data)) {
+ PushServerUpdateJob::dispatch($server, $data);
+ }
+
+ auditLog('sentinel.metrics_pushed', [
+ 'server_uuid' => $server->uuid,
+ 'team_id' => $server->team_id,
+ ]);
+
+ return response()->json(['message' => 'ok'], 200);
+ }
+
+ /**
+ * Decide whether PushServerUpdateJob should be dispatched for this push.
+ *
+ * Dispatches when: first push (no cached hash), the container state changed,
+ * or the force window elapsed.
+ */
+ private function shouldDispatchUpdate(Server $server, array $data): bool
+ {
+ $hash = $this->containerStateHash($data);
+ $hashKey = "sentinel:push-hash:{$server->id}";
+ $forceKey = "sentinel:push-force:{$server->id}";
+ $lockKey = "sentinel:push-lock:{$server->id}";
+
+ try {
+ return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
+ $cachedHash = Cache::get($hashKey);
+ $forceActive = Cache::has($forceKey);
+
+ $shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
+
+ if ($shouldDispatch) {
+ // Day-long TTL bounds memory if a server stops pushing entirely.
+ Cache::put($hashKey, $hash, now()->addDay());
+ Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
+ }
+
+ return $shouldDispatch;
+ });
+ } catch (LockTimeoutException) {
+ return false;
+ }
+ }
+
+ /**
+ * Build a stable hash of container state.
+ *
+ * Covers [name, state] only — metrics, filesystem_usage_root, and
+ * health_status are excluded on purpose. Disk % churns constantly, and
+ * health checks can flap between starting/healthy/unhealthy while the
+ * container lifecycle state remains unchanged. Both would otherwise defeat
+ * the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
+ * The force window still refreshes full state periodically. Sorted by name
+ * so container ordering from Sentinel does not affect the hash.
+ */
+ private function containerStateHash(array $data): string
+ {
+ $containers = collect(data_get($data, 'containers', []))
+ ->map(fn ($c) => [
+ 'name' => data_get($c, 'name'),
+ 'state' => data_get($c, 'state'),
+ ])
+ ->sortBy('name')
+ ->values()
+ ->all();
+
+ return hash('xxh128', json_encode($containers));
+ }
+}
diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php
index c13c6665c..6c3b2da00 100644
--- a/app/Http/Controllers/Api/ServersController.php
+++ b/app/Http/Controllers/Api/ServersController.php
@@ -13,6 +13,7 @@ use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@@ -477,7 +478,7 @@ class ServersController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -564,6 +565,14 @@ class ServersController extends Controller
ValidateServer::dispatch($server);
}
+ auditLog('api.server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'ip' => $server->ip,
+ 'is_build_server' => (bool) $request->is_build_server,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -603,6 +612,7 @@ class ServersController extends Controller
'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.'],
],
),
),
@@ -639,7 +649,7 @@ class ServersController extends Controller
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', '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)) {
@@ -647,7 +657,7 @@ class ServersController extends Controller
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -665,6 +675,7 @@ class ServersController extends Controller
'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);
@@ -709,7 +720,7 @@ class ServersController extends Controller
], 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)));
}
@@ -718,6 +729,13 @@ class ServersController extends Controller
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);
@@ -807,6 +825,9 @@ class ServersController extends Controller
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -816,6 +837,14 @@ class ServersController extends Controller
$server->team_id
);
+ auditLog('api.server.deleted', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $deletedUuid,
+ 'server_name' => $deletedName,
+ 'ip' => $deletedIp,
+ 'force' => $force,
+ ]);
+
return response()->json(['message' => 'Server deleted.']);
}
@@ -881,6 +910,12 @@ class ServersController extends Controller
}
ValidateServer::dispatch($server);
+ auditLog('api.server.validated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ ]);
+
return response()->json(['message' => 'Validation started.'], 201);
}
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 23ba30998..11a23d46c 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -486,6 +486,14 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => $oneClickServiceName ?? null,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -650,6 +658,14 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => 'docker_compose',
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -792,6 +808,12 @@ class ServicesController extends Controller
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.service.deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json([
'message' => 'Service deletion request queued.',
]);
@@ -1046,6 +1068,13 @@ class ServicesController extends Controller
StartService::dispatch($service);
}
+ auditLog('api.service.updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -1255,6 +1284,13 @@ class ServicesController extends Controller
}
$env->save();
+ auditLog('api.service.env_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1384,6 +1420,12 @@ class ServicesController extends Controller
$updatedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.service.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -1506,6 +1548,13 @@ class ServicesController extends Controller
'comment' => $request->comment ?? null,
]);
+ auditLog('api.service.env_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1591,8 +1640,17 @@ class ServicesController extends Controller
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.service.env_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -1668,6 +1726,12 @@ class ServicesController extends Controller
}
StartService::dispatch($service);
+ auditLog('api.service.deployed', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json(
[
'message' => 'Service starting request queued.',
@@ -1759,6 +1823,13 @@ class ServicesController extends Controller
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
+ auditLog('api.service.stopped', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Service stopping request queued.',
@@ -1846,6 +1917,13 @@ class ServicesController extends Controller
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
+ auditLog('api.service.restarted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'pull_latest' => $pullLatest,
+ ]);
+
return response()->json(
[
'message' => 'Service restarting request queued.',
@@ -2018,7 +2096,7 @@ class ServicesController extends Controller
'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',
@@ -2126,6 +2204,15 @@ class ServicesController extends Controller
]);
}
+ auditLog('api.service.storage_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -2227,7 +2314,7 @@ class ServicesController extends Controller
'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',
]);
@@ -2354,6 +2441,15 @@ class ServicesController extends Controller
$storage->save();
+ auditLog('api.service.storage_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -2454,8 +2550,18 @@ class ServicesController extends Controller
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.service.storage_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 17d14296b..3090538c3 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -6,8 +6,9 @@ use App\Events\TestEvent;
use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
+use Illuminate\Auth\Events\Verified;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
@@ -39,9 +40,29 @@ class Controller extends BaseController
return view('auth.verify-email');
}
- public function email_verify(EmailVerificationRequest $request)
+ public function email_verify(Request $request)
{
- $request->fulfill();
+ if (! $request->hasValidSignature()) {
+ abort(403);
+ }
+
+ $user = auth()->user();
+ if (! $user) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
+ abort(403);
+ }
+
+ if (! $user->hasVerifiedEmail()) {
+ $user->markEmailAsVerified();
+ event(new Verified($user));
+ }
return redirect(RouteServiceProvider::HOME);
}
@@ -78,27 +99,50 @@ class Controller extends BaseController
{
$token = request()->get('token');
if ($token) {
- $decrypted = Crypt::decryptString($token);
- $email = str($decrypted)->before('@@@');
- $password = str($decrypted)->after('@@@');
+ try {
+ $decrypted = Crypt::decryptString($token);
+ } catch (DecryptException) {
+ return redirect()->route('login')->with('error', 'Invalid credentials.');
+ }
+
+ if (! str_contains($decrypted, '@@@')) {
+ return redirect()->route('login')->with('error', 'Invalid credentials.');
+ }
+
+ $payload = explode('@@@', $decrypted, 3);
+ if (count($payload) === 3) {
+ [$email, $invitationUuid, $password] = $payload;
+ } else {
+ [$email, $password] = $payload;
+ $invitationUuid = null;
+ }
+
+ $email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
+
+ $invitation = TeamInvitation::query()
+ ->where('email', $email)
+ ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
+ ->where('link', request()->fullUrl())
+ ->first();
+ if (! $invitation || ! $invitation->isValid()) {
+ return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
+ }
+
if (Hash::check($password, $user->password)) {
- $invitation = TeamInvitation::whereEmail($email);
- if ($invitation->exists()) {
- $team = $invitation->first()->team;
- $user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
- $invitation->delete();
- } else {
- $team = $user->teams()->first();
- }
- if (is_null(data_get($user, 'email_verified_at'))) {
- $user->email_verified_at = now();
- $user->save();
+ $team = $invitation->team;
+ if (! $user->teams()->where('team_id', $team->id)->exists()) {
+ $user->teams()->attach($team->id, ['role' => $invitation->role]);
}
+ $invitation->delete();
+
Auth::login($user);
+ $user->forceFill([
+ 'password' => Hash::make(Str::random(64)),
+ ])->save();
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 3a3f18c9c..4038fe63e 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -19,7 +19,12 @@ class OauthController extends Controller
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ class OauthController extends Controller
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 93847589a..6c3dda402 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -11,6 +11,27 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
class UploadController extends BaseController
{
+ private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
+
+ private const ALLOWED_EXTENSIONS = [
+ 'sql',
+ 'sql.gz',
+ 'gz',
+ 'zip',
+ 'tar',
+ 'tar.gz',
+ 'tgz',
+ 'dump',
+ 'bak',
+ 'bson',
+ 'bson.gz',
+ 'archive',
+ 'archive.gz',
+ 'bz2',
+ 'xz',
+ 'dmp',
+ ];
+
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@@ -18,6 +39,22 @@ class UploadController extends BaseController
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
+
+ $chunk = $request->file('file');
+ $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
+ if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
+ return response()->json([
+ 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
+ ], 422);
+ }
+
+ $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
+ if ($declaredTotalSize > self::MAX_BYTES) {
+ return response()->json([
+ 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
+ ], 422);
+ }
+
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
@@ -40,29 +77,20 @@ class UploadController extends BaseController
'status' => true,
]);
}
- // protected function saveFileToS3($file)
- // {
- // $fileName = $this->createFilename($file);
- // $disk = Storage::disk('s3');
- // // It's better to use streaming Streaming (laravel 5.4+)
- // $disk->putFileAs('photos', $file, $fileName);
-
- // // for older laravel
- // // $disk->put($fileName, file_get_contents($file), 'public');
- // $mime = str_replace('/', '-', $file->getMimeType());
-
- // // We need to delete the file when uploaded to s3
- // unlink($file->getPathname());
-
- // return response()->json([
- // 'path' => $disk->url($fileName),
- // 'name' => $fileName,
- // 'mime_type' => $mime
- // ]);
- // }
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
+ $originalName = $file->getClientOriginalName();
+ $size = $file->getSize();
+
+ if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
+ @unlink($file->getPathname());
+
+ return response()->json([
+ 'error' => 'Uploaded file failed validation.',
+ ], 422);
+ }
+
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
@@ -73,13 +101,30 @@ class UploadController extends BaseController
]);
}
- protected function createFilename(UploadedFile $file)
+ private static function hasAllowedExtension(string $name): bool
{
- $extension = $file->getClientOriginalExtension();
- $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
+ $lower = strtolower($name);
+ $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
+ usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
- $filename .= '_'.md5(time()).'.'.$extension;
+ foreach ($suffixes as $suffix) {
+ if (! str_ends_with($lower, $suffix)) {
+ continue;
+ }
- return $filename;
+ $stem = substr($lower, 0, -strlen($suffix));
+ if ($stem !== '' && ! str_ends_with($stem, '.')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private static function formatMaxSize(): string
+ {
+ return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
}
}
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index 183186711..d37ba7cee 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -12,6 +14,9 @@ use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -31,6 +36,16 @@ class Bitbucket extends Controller
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
+ // Bitbucket webhooks ship up to 5 commits per change. Larger pushes
+ // are evaluated only on the visible 5.
+ $skip_deploy_commits = self::shouldSkipDeploy(
+ collect(data_get($payload, 'push.changes', []))
+ ->flatMap(fn ($change) => data_get($change, 'commits', []))
+ ->pluck('message')
+ ->filter()
+ ->values()
+ ->all()
+ );
if (! $branch) {
return response([
@@ -45,10 +60,18 @@ class Bitbucket extends Controller
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
+ $pull_request_title = data_get($payload, 'pullrequest.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
- $applications = $applications->where('git_branch', $branch)->get();
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response([
+ 'status' => 'failed',
+ 'message' => 'Nothing to do. Invalid repository.',
+ ]);
+ }
+ $applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([
'status' => 'failed',
@@ -57,16 +80,41 @@ class Bitbucket extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$payload = $request->getContent();
- [$algo, $hash] = explode('=', $x_bitbucket_token, 2);
- $payloadHash = hash_hmac($algo, $payload, $webhook_secret);
- if (! hash_equals($hash, $payloadHash) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ $parts = explode('=', $x_bitbucket_token, 2);
+ if (count($parts) !== 2 || $parts[0] !== 'sha256') {
+ auditLogWebhookFailure('bitbucket', 'malformed_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
+ $hash = $parts[1];
+ $payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
+ if (! hash_equals($hash, $payloadHash) && ! isDev()) {
+ auditLogWebhookFailure('bitbucket', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_bitbucket_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -82,6 +130,17 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -99,6 +158,15 @@ class Bitbucket extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'bitbucket',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => $commit,
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -115,6 +183,15 @@ class Bitbucket extends Controller
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
new file mode 100644
index 000000000..69695e99b
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
@@ -0,0 +1,55 @@
+ $messages
+ */
+ public static function shouldSkipDeploy(array $messages): bool
+ {
+ $messages = array_values(array_filter($messages, fn ($m) => filled($m)));
+
+ if (empty($messages)) {
+ return false;
+ }
+
+ foreach ($messages as $message) {
+ $lower = strtolower((string) $message);
+ if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if at least one non-empty message contains [skip cd] or
+ * [skip ci]. Used for PR/MR title + latest-commit signals where any one
+ * marker should trigger the skip.
+ *
+ * @param array $messages
+ */
+ public static function shouldSkipDeployAny(array $messages): bool
+ {
+ foreach ($messages as $message) {
+ if (! filled($message)) {
+ continue;
+ }
+ $lower = strtolower((string) $message);
+ if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php
new file mode 100644
index 000000000..0463790eb
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/MatchesManualWebhookApplications.php
@@ -0,0 +1,108 @@
+normalizeManualWebhookRepositoryPath($fullName);
+ }
+
+ /**
+ * @return Collection
+ */
+ protected function manualWebhookApplications(Builder $query, string $fullName): Collection
+ {
+ return $query->get()
+ ->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
+ ->values();
+ }
+
+ protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
+ {
+ $repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
+
+ if ($repositoryPath === null) {
+ return false;
+ }
+
+ // Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
+ // case-insensitively, so compare the canonical paths case-insensitively.
+ return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
+ }
+
+ /**
+ * @return array{status: string, message: string}
+ */
+ protected function unauthenticatedManualWebhookFailurePayload(): array
+ {
+ return [
+ 'status' => 'failed',
+ 'message' => 'Invalid signature.',
+ ];
+ }
+
+ protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
+ {
+ if (! is_string($gitRepository)) {
+ return null;
+ }
+
+ $gitRepository = trim($gitRepository);
+
+ if ($gitRepository === '') {
+ return null;
+ }
+
+ $path = null;
+ $parts = parse_url($gitRepository);
+
+ if (is_array($parts) && isset($parts['scheme'])) {
+ $path = data_get($parts, 'path');
+ } elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
+ $path = Str::after($gitRepository, ':');
+ // scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
+ // Strip the leading numeric port segment so the path matches the webhook
+ // payload's owner/repo, consistent with convertGitUrl() in shared.php.
+ $path = preg_replace('#^\d+/#', '', $path) ?? $path;
+ } else {
+ $path = $gitRepository;
+ }
+
+ if (! is_string($path) || $path === '') {
+ return null;
+ }
+
+ return $this->normalizeManualWebhookRepositoryPath($path);
+ }
+
+ protected function normalizeManualWebhookRepositoryPath(string $path): string
+ {
+ $path = trim($path);
+ $path = strtok($path, '?#') ?: $path;
+ $path = trim($path, '/');
+ $path = preg_replace('/\.git\z/i', '', $path) ?? $path;
+
+ return $path;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index a9d65eae6..be064e380 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +15,9 @@ use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -40,40 +45,60 @@ class Gitea extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response('Nothing to do. Invalid repository.');
+ }
+ $applications = Application::query();
if ($x_gitea_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_gitea_event === 'pull_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ auditLogWebhookFailure('gitea', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitea_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -91,6 +116,17 @@ class Gitea extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -108,6 +144,15 @@ class Gitea extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitea',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -140,6 +185,15 @@ class Gitea extends Controller
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index fe49369ea..40c5cbdf0 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -3,19 +3,27 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
+use Illuminate\Http\Exceptions\HttpResponseException;
+use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Github extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -43,17 +51,20 @@ class Github extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
+ $is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -61,15 +72,19 @@ class Github extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ return response('Nothing to do. Invalid repository.');
+ }
+ $applications = Application::query();
if ($x_github_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_github_event === 'pull_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@@ -81,13 +96,26 @@ class Github extends Controller
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('github', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'mode' => 'manual',
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -105,6 +133,17 @@ class Github extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -122,6 +161,15 @@ class Github extends Controller
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -171,11 +219,13 @@ class Github extends Controller
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -215,6 +265,13 @@ class Github extends Controller
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'mode' => 'app',
+ 'github_app_id' => $github_app->id,
+ 'github_app_name' => $github_app->name,
+ 'installation_target_id' => $x_github_hook_installation_target_id,
+ ]);
+
return response('Invalid signature.');
}
}
@@ -237,17 +294,20 @@ class Github extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
+ $is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -291,6 +351,17 @@ class Github extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -302,6 +373,17 @@ class Github extends Controller
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
+ if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'app',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'github_app_id' => $github_app->id,
+ ]);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -351,11 +433,13 @@ class Github extends Controller
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -373,55 +457,203 @@ class Github extends Controller
}
}
+ /**
+ * Determine whether a pull_request webhook payload originates from a fork.
+ *
+ * GitHub's `author_association` is not a reliable trust signal (it grants
+ * CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
+ * detection is gated on whether the PR crosses repository boundaries.
+ *
+ * The repository id comparison is the canonical signal; the `head.repo.fork`
+ * flag and a case-insensitive full_name comparison are fallbacks for payloads
+ * where the ids are unavailable (e.g. a deleted head repository).
+ */
+ private function isForkPullRequest(mixed $payload): bool
+ {
+ $headRepoId = data_get($payload, 'pull_request.head.repo.id');
+ $baseRepoId = data_get($payload, 'pull_request.base.repo.id');
+
+ if ($headRepoId !== null && $baseRepoId !== null) {
+ return (string) $headRepoId !== (string) $baseRepoId;
+ }
+
+ if (data_get($payload, 'pull_request.head.repo.fork') === true) {
+ return true;
+ }
+
+ $headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
+ $baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
+
+ if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
+ return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
+ }
+
+ return false;
+ }
+
public function redirect(Request $request)
{
- try {
- $code = $request->get('code');
- $state = $request->get('state');
- $github_app = GithubApp::where('uuid', $state)->firstOrFail();
- $api_url = data_get($github_app, 'api_url');
- $data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
- $id = data_get($data, 'id');
- $slug = data_get($data, 'slug');
- $client_id = data_get($data, 'client_id');
- $client_secret = data_get($data, 'client_secret');
- $private_key = data_get($data, 'pem');
- $webhook_secret = data_get($data, 'webhook_secret');
- $private_key = PrivateKey::create([
- 'name' => "github-app-{$slug}",
- 'private_key' => $private_key,
- 'team_id' => $github_app->team_id,
- 'is_git_related' => true,
- ]);
- $github_app->name = $slug;
- $github_app->app_id = $id;
- $github_app->client_id = $client_id;
- $github_app->client_secret = $client_secret;
- $github_app->webhook_secret = $webhook_secret;
- $github_app->private_key_id = $private_key->id;
- $github_app->save();
+ $code = (string) $request->query('code', '');
+ abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
- return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
- } catch (Exception $e) {
- return handleError($e);
- }
+ $github_app = $this->consumeGithubAppSetupState(
+ request: $request,
+ state: (string) $request->query('state', ''),
+ action: 'manifest',
+ );
+
+ abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
+
+ $api_url = data_get($github_app, 'api_url');
+ $data = Http::withBody(null)
+ ->accept('application/vnd.github+json')
+ ->timeout(10)
+ ->connectTimeout(5)
+ ->post("$api_url/app-manifests/$code/conversions")
+ ->throw()
+ ->json();
+
+ $id = data_get($data, 'id');
+ $slug = data_get($data, 'slug');
+ $client_id = data_get($data, 'client_id');
+ $client_secret = data_get($data, 'client_secret');
+ $private_key = data_get($data, 'pem');
+ $webhook_secret = data_get($data, 'webhook_secret');
+
+ abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
+
+ $private_key = PrivateKey::create([
+ 'name' => "github-app-{$slug}",
+ 'private_key' => $private_key,
+ 'team_id' => $github_app->team_id,
+ 'is_git_related' => true,
+ ]);
+ $github_app->name = $slug;
+ $github_app->app_id = $id;
+ $github_app->client_id = $client_id;
+ $github_app->client_secret = $client_secret;
+ $github_app->webhook_secret = $webhook_secret;
+ $github_app->private_key_id = $private_key->id;
+ $github_app->save();
+
+ return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
- try {
- $installation_id = $request->get('installation_id');
- $source = $request->get('source');
- $setup_action = $request->get('setup_action');
- $github_app = GithubApp::where('uuid', $source)->firstOrFail();
- if ($setup_action === 'install') {
- $github_app->installation_id = $installation_id;
- $github_app->save();
- }
+ $setup_action = (string) $request->query('setup_action', '');
+ abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
+ $installation_id = (string) $request->query('installation_id', '');
+ abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
+
+ if ($setup_action === 'update') {
+ return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
+ }
+
+ $github_app = $this->consumeGithubAppSetupState(
+ request: $request,
+ state: (string) $request->query('state', ''),
+ action: 'install',
+ );
+
+ abort_unless(
+ $this->githubInstallationBelongsToApp($github_app, $installation_id),
+ 403,
+ 'GitHub App installation could not be verified.'
+ );
+
+ $github_app->installation_id = $installation_id;
+ $github_app->save();
+
+ return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
+ }
+
+ private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
+ {
+ $github_app = GithubApp::ownedByCurrentTeam()
+ ->where('installation_id', $installation_id)
+ ->first();
+
+ if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
- } catch (Exception $e) {
- return handleError($e);
+ }
+
+ return redirect()->route('source.all');
+ }
+
+ /**
+ * Verify that the given installation id actually belongs to this GitHub App.
+ *
+ * The installation id arrives as an untrusted query parameter on an
+ * unauthenticated-reachable GET callback, so it must be confirmed against
+ * the GitHub API using the App's own credentials before it is persisted.
+ */
+ private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
+ {
+ if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
+ return false;
+ }
+
+ try {
+ $jwt = generateGithubJwt($github_app);
+ $response = Http::withHeaders([
+ 'Authorization' => "Bearer $jwt",
+ 'Accept' => 'application/vnd.github+json',
+ ])
+ ->timeout(10)
+ ->connectTimeout(5)
+ ->get("{$github_app->api_url}/app/installations/{$installation_id}");
+
+ return $response->successful()
+ && (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
+ } catch (\Throwable) {
+ return false;
}
}
+
+ private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
+ {
+ if (blank($state)) {
+ $this->rejectInvalidGithubAppSetupState($request);
+ }
+
+ $payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
+ if (! is_array($payload) || data_get($payload, 'action') !== $action) {
+ $this->rejectInvalidGithubAppSetupState($request);
+ }
+
+ $team_id = $request->user()?->currentTeam()?->id;
+ abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
+
+ return GithubApp::whereKey(data_get($payload, 'github_app_id'))
+ ->where('team_id', data_get($payload, 'team_id'))
+ ->firstOrFail();
+ }
+
+ private function rejectInvalidGithubAppSetupState(Request $request): never
+ {
+ if ($request->expectsJson()) {
+ abort(404);
+ }
+
+ throw new HttpResponseException(
+ redirect()
+ ->route('source.all')
+ );
+ }
+
+ private function githubAppSetupStateCacheKey(string $state): string
+ {
+ return 'github-app-setup-state:'.hash('sha256', $state);
+ }
+
+ private function githubAppHasManifestCredentials(GithubApp $github_app): bool
+ {
+ return filled($github_app->app_id)
+ || filled($github_app->client_id)
+ || filled($github_app->client_secret)
+ || filled($github_app->webhook_secret)
+ || filled($github_app->private_key_id);
+ }
}
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 08e5d7162..231a0b6e5 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
+use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -13,6 +15,9 @@ use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -32,6 +37,9 @@ class Gitlab extends Controller
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +66,7 @@ class Gitlab extends Controller
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@@ -66,6 +75,9 @@ class Gitlab extends Controller
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
+ $pull_request_title = data_get($payload, 'object_attributes.title');
+ $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@@ -75,9 +87,18 @@ class Gitlab extends Controller
return response($return_payloads);
}
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
+ $full_name = $this->manualWebhookRepositoryFullName($full_name);
+ if ($full_name === null) {
+ $return_payloads->push([
+ 'status' => 'failed',
+ 'message' => 'Nothing to do. Invalid repository.',
+ ]);
+
+ return response($return_payloads);
+ }
+ $applications = Application::query();
if ($x_gitlab_event === 'push') {
- $applications = $applications->where('git_branch', $branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -88,7 +109,7 @@ class Gitlab extends Controller
}
}
if ($x_gitlab_event === 'merge_request') {
- $applications = $applications->where('git_branch', $base_branch)->get();
+ $applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -100,12 +121,25 @@ class Gitlab extends Controller
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
- if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ if (empty($webhook_secret)) {
+ auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
+
+ continue;
+ }
+ if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
+ auditLogWebhookFailure('gitlab', 'invalid_signature', [
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'repository' => $full_name ?? null,
+ 'event' => $x_gitlab_event,
+ ]);
+ $return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -123,6 +157,17 @@ class Gitlab extends Controller
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -141,6 +186,15 @@ class Gitlab extends Controller
'application_name' => $application->name,
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitlab',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -173,6 +227,15 @@ class Gitlab extends Controller
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index d59adf0ca..41e70b2ce 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,6 +6,8 @@ use App\Http\Controllers\Controller;
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ class Stripe extends Controller
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
- $event = \Stripe\Webhook::constructEvent(
+ $event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@@ -22,6 +24,12 @@ class Stripe extends Controller
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
+ } catch (SignatureVerificationException $e) {
+ auditLogWebhookFailure('stripe', 'invalid_signature', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 515d40c62..02a49aaa8 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -2,7 +2,41 @@
namespace App\Http;
+use App\Http\Middleware\ApiAbility;
+use App\Http\Middleware\ApiSensitiveData;
+use App\Http\Middleware\Authenticate;
+use App\Http\Middleware\CanAccessTerminal;
+use App\Http\Middleware\CanCreateResources;
+use App\Http\Middleware\CanUpdateResource;
+use App\Http\Middleware\CheckForcePasswordReset;
+use App\Http\Middleware\DecideWhatToDoWithUser;
+use App\Http\Middleware\EncryptCookies;
+use App\Http\Middleware\EnsureMcpEnabled;
+use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
+use App\Http\Middleware\PreventRequestsDuringMaintenance;
+use App\Http\Middleware\RedirectIfAuthenticated;
+use App\Http\Middleware\TrimStrings;
+use App\Http\Middleware\TrustHosts;
+use App\Http\Middleware\TrustProxies;
+use App\Http\Middleware\ValidateSignature;
+use App\Http\Middleware\VerifyCsrfToken;
+use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
+use Illuminate\Auth\Middleware\Authorize;
+use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
+use Illuminate\Auth\Middleware\RequirePassword;
+use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
+use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
+use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
+use Illuminate\Http\Middleware\HandleCors;
+use Illuminate\Http\Middleware\SetCacheHeaders;
+use Illuminate\Routing\Middleware\SubstituteBindings;
+use Illuminate\Routing\Middleware\ThrottleRequests;
+use Illuminate\Session\Middleware\AuthenticateSession;
+use Illuminate\Session\Middleware\StartSession;
+use Illuminate\View\Middleware\ShareErrorsFromSession;
+use Laravel\Sanctum\Http\Middleware\CheckAbilities;
+use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel
{
@@ -14,13 +48,13 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middleware = [
- \App\Http\Middleware\TrustHosts::class,
- \App\Http\Middleware\TrustProxies::class,
- \Illuminate\Http\Middleware\HandleCors::class,
- \App\Http\Middleware\PreventRequestsDuringMaintenance::class,
- \Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
- \App\Http\Middleware\TrimStrings::class,
- \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
+ TrustHosts::class,
+ TrustProxies::class,
+ HandleCors::class,
+ PreventRequestsDuringMaintenance::class,
+ ValidatePostSize::class,
+ TrimStrings::class,
+ ConvertEmptyStringsToNull::class,
];
@@ -31,21 +65,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
- \App\Http\Middleware\EncryptCookies::class,
- \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
- \Illuminate\Session\Middleware\StartSession::class,
- \Illuminate\View\Middleware\ShareErrorsFromSession::class,
- \App\Http\Middleware\VerifyCsrfToken::class,
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
- \App\Http\Middleware\CheckForcePasswordReset::class,
- \App\Http\Middleware\DecideWhatToDoWithUser::class,
+ EncryptCookies::class,
+ AddQueuedCookiesToResponse::class,
+ StartSession::class,
+ ShareErrorsFromSession::class,
+ VerifyCsrfToken::class,
+ SubstituteBindings::class,
+ CheckForcePasswordReset::class,
+ DecideWhatToDoWithUser::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
- \Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
- \Illuminate\Routing\Middleware\SubstituteBindings::class,
+ ThrottleRequests::class.':api',
+ SubstituteBindings::class,
],
];
@@ -57,22 +91,24 @@ class Kernel extends HttpKernel
* @var array
*/
protected $middlewareAliases = [
- 'auth' => \App\Http\Middleware\Authenticate::class,
- 'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
- 'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
- 'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
- 'can' => \Illuminate\Auth\Middleware\Authorize::class,
- 'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
- 'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
- 'signed' => \App\Http\Middleware\ValidateSignature::class,
- 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
- 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
- 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
- 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
- 'api.ability' => \App\Http\Middleware\ApiAbility::class,
- 'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
- 'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
- 'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
- 'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
+ 'auth' => Authenticate::class,
+ 'auth.basic' => AuthenticateWithBasicAuth::class,
+ 'auth.session' => AuthenticateSession::class,
+ 'cache.headers' => SetCacheHeaders::class,
+ 'can' => Authorize::class,
+ 'guest' => RedirectIfAuthenticated::class,
+ 'password.confirm' => RequirePassword::class,
+ 'signed' => ValidateSignature::class,
+ 'throttle' => ThrottleRequests::class,
+ 'verified' => EnsureEmailIsVerified::class,
+ 'abilities' => CheckAbilities::class,
+ 'ability' => CheckForAnyAbility::class,
+ 'api.ability' => ApiAbility::class,
+ 'api.sensitive' => ApiSensitiveData::class,
+ 'api.token.team' => EnsureTokenBelongsToCurrentTeamMember::class,
+ 'can.create.resources' => CanCreateResources::class,
+ 'can.update.resource' => CanUpdateResource::class,
+ 'can.access.terminal' => CanAccessTerminal::class,
+ 'mcp.enabled' => EnsureMcpEnabled::class,
];
}
diff --git a/app/Http/Middleware/ApiAbility.php b/app/Http/Middleware/ApiAbility.php
index 324eeebaa..f81c7d184 100644
--- a/app/Http/Middleware/ApiAbility.php
+++ b/app/Http/Middleware/ApiAbility.php
@@ -2,6 +2,7 @@
namespace App\Http\Middleware;
+use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@@ -14,11 +15,22 @@ class ApiAbility extends CheckForAnyAbility
}
return parent::handle($request, $next, ...$abilities);
- } catch (\Illuminate\Auth\AuthenticationException $e) {
+ } catch (AuthenticationException $e) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $e->getMessage(),
+ 'required_abilities' => $abilities,
+ ], 'warning');
+
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
+ auditLog('api.auth.ability_denied', [
+ 'required_abilities' => $abilities,
+ 'token_id' => $request->user()?->currentAccessToken()?->id,
+ 'reason' => $e->getMessage(),
+ ], 'warning');
+
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);
diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php
new file mode 100644
index 000000000..9c4f1339c
--- /dev/null
+++ b/app/Http/Middleware/EnsureMcpEnabled.php
@@ -0,0 +1,25 @@
+is_mcp_server_enabled) {
+ abort(404);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php
new file mode 100644
index 000000000..7c858b38b
--- /dev/null
+++ b/app/Http/Middleware/EnsureTokenBelongsToCurrentTeamMember.php
@@ -0,0 +1,37 @@
+user();
+ $token = $user?->currentAccessToken();
+ $teamId = $token?->team_id;
+
+ if (! $user || ! $token || is_null($teamId)) {
+ return response()->json(['message' => 'Invalid token.'], 401);
+ }
+
+ $team = $user->teams()
+ ->where('teams.id', $teamId)
+ ->first();
+
+ if (! $team) {
+ return response()->json(['message' => 'Invalid token.'], 401);
+ }
+
+ $role = $team->pivot?->role;
+ if (($token->can('root') || $token->can('write') || $token->can('write:sensitive'))
+ && ! in_array($role, ['admin', 'owner'], true)) {
+ return response()->json(['message' => 'Missing required team role.'], 403);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php
new file mode 100644
index 000000000..e7b34248e
--- /dev/null
+++ b/app/Jobs/ApiTokenExpirationWarningJob.php
@@ -0,0 +1,64 @@
+whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->whereNull('api_token_expiration_warning_sent_at')
+ ->where('tokenable_type', User::class)
+ ->chunkById(100, function ($tokens) {
+ foreach ($tokens as $token) {
+ if (! $token->team_id) {
+ continue;
+ }
+
+ $team = Team::find($token->team_id);
+ if (! $team) {
+ continue;
+ }
+
+ $warningSentAt = now();
+
+ $team->notify(new ApiTokenExpiringNotification($token));
+
+ $markedAsSent = PersonalAccessToken::query()
+ ->whereKey($token->getKey())
+ ->whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->whereNull('api_token_expiration_warning_sent_at')
+ ->update(['api_token_expiration_warning_sent_at' => $warningSentAt]);
+
+ if ($markedAsSent !== 1) {
+ continue;
+ }
+
+ $token->forceFill(['api_token_expiration_warning_sent_at' => $warningSentAt]);
+ }
+ });
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index 7e5025c8a..811d0c9bd 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -33,6 +33,7 @@ use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
+use JsonException;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
use Throwable;
@@ -48,6 +49,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const NIXPACKS_PLAN_PATH = '/artifacts/thegameplan.json';
+ private const RAILPACK_REPOSITORY_CONFIG_PATH = 'railpack.json';
+
+ private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
+
public $tries = 1;
public $timeout = 3600;
@@ -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;
@@ -188,7 +197,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public int $application_deployment_queue_id)
{
- $this->onQueue('high');
+ $this->onQueue(deployment_queue());
$this->application_deployment_queue = ApplicationDeploymentQueue::find($this->application_deployment_queue_id);
$this->nixpacks_plan_json = collect([]);
@@ -211,6 +220,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->restart_only = $this->restart_only && $this->application->build_pack !== 'dockerimage' && $this->application->build_pack !== 'dockerfile';
$this->only_this_server = $this->application_deployment_queue->only_this_server;
$this->dockerImagePreviewTag = $this->application_deployment_queue->docker_registry_image_tag;
+ $this->validateDockerRegistryImageConfiguration();
$this->git_type = data_get($this->application_deployment_queue, 'git_type');
@@ -414,6 +424,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
@@ -427,8 +438,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
+ $this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
+ $this->dockerBuildxAvailable = false;
+
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@@ -461,6 +475,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
@@ -484,8 +499,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$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();
}
@@ -519,11 +538,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
}
- try {
- $this->application->isConfigurationChanged(true);
- } catch (Exception $e) {
- \Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
- }
}
private function deploy_simple_dockerfile()
@@ -938,6 +952,37 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->rolling_update();
}
+ private function deploy_railpack_buildpack(): void
+ {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ }
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
+ $this->prepare_builder_image();
+ $this->check_git_if_build_needed();
+ $this->generate_image_names();
+ if (! $this->force_rebuild) {
+ $this->check_image_locally_or_remotely();
+ if ($this->should_skip_build()) {
+ return;
+ }
+ }
+ $this->clone_repository();
+ $this->cleanup_git();
+ $this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
+ $this->generate_build_env_variables();
+ $this->build_railpack_image();
+
+ // Save runtime environment variables AFTER the build
+ $this->save_runtime_environment_variables();
+ $this->push_to_docker_registry();
+ $this->rolling_update();
+ }
+
private function deploy_static_buildpack()
{
if ($this->use_build_server) {
@@ -1062,7 +1107,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'hidden' => true,
],
);
- if ($this->application->docker_registry_image_tag) {
+ if ($this->shouldPushDockerRegistryImageTag()) {
// Tag image with docker_registry_image_tag
$this->application_deployment_queue->addLogEntry("Tagging and pushing image with {$this->application->docker_registry_image_tag} tag.");
$this->execute_remote_command(
@@ -1086,6 +1131,30 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ private function shouldPushDockerRegistryImageTag(): bool
+ {
+ if (blank($this->application->docker_registry_image_tag)) {
+ return false;
+ }
+
+ return $this->pull_request_id === 0;
+ }
+
+ private function validateDockerRegistryImageConfiguration(): void
+ {
+ if (! ValidationPatterns::isValidDockerImageName($this->application->docker_registry_image_name)) {
+ throw new DeploymentException('Docker registry image name contains invalid characters.');
+ }
+
+ if (! ValidationPatterns::isValidDockerImageTag($this->application->docker_registry_image_tag)) {
+ throw new DeploymentException('Docker registry image tag contains invalid characters.');
+ }
+
+ if (! ValidationPatterns::isValidDockerImageTag($this->dockerImagePreviewTag)) {
+ throw new DeploymentException('Docker registry preview image tag contains invalid characters.');
+ }
+ }
+
private function generate_image_names()
{
if ($this->application->dockerfile) {
@@ -1105,12 +1174,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) {
+ $previewImageTag = $this->previewImageTag();
+ $previewBuildImageTag = $this->previewImageTag(build: true);
+
if ($this->application->docker_registry_image_name) {
- $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->docker_registry_image_name}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->docker_registry_image_name}:{$previewImageTag}";
} else {
- $this->build_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}-build";
- $this->production_image_name = "{$this->application->uuid}:pr-{$this->pull_request_id}";
+ $this->build_image_name = "{$this->application->uuid}:{$previewBuildImageTag}";
+ $this->production_image_name = "{$this->application->uuid}:{$previewImageTag}";
}
} else {
$this->dockerImageTag = str($this->commit)->substr(0, 128);
@@ -1127,6 +1199,27 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ private function previewImageTag(bool $build = false): string
+ {
+ $prefix = "pr-{$this->pull_request_id}-";
+ $suffix = $build ? '-build' : '';
+ $maxCommitLength = max(1, 128 - strlen($prefix) - strlen($suffix));
+ $commitSource = ($this->commit === 'HEAD' || blank($this->commit))
+ ? $this->deployment_uuid
+ : $this->commit;
+
+ $commit = Str::of($commitSource)
+ ->replaceMatches('/[^A-Za-z0-9_.-]/', '-')
+ ->substr(0, $maxCommitLength)
+ ->toString();
+
+ if ($commit === '') {
+ $commit = 'HEAD';
+ }
+
+ return "{$prefix}{$commit}{$suffix}";
+ }
+
private function just_restart()
{
$this->application_deployment_queue->addLogEntry("Restarting {$this->customRepository}:{$this->application->git_branch} on {$this->server->name}.");
@@ -1165,8 +1258,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
- if (! $this->application->isConfigurationChanged()) {
- $this->application_deployment_queue->addLogEntry("No configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
+ $configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
+ if (! $configurationDiff->requiresBuild()) {
+ $this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
$this->skip_build = true;
$this->generate_compose_file();
@@ -1178,7 +1272,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return true;
} else {
- $this->application_deployment_queue->addLogEntry('Configuration changed. Rebuilding image.');
+ $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.');
}
} else {
$this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image.");
@@ -1217,19 +1311,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('key');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('id');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
- });
- $sorted_environment_variables_preview = $sorted_environment_variables_preview->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_') && ! str($env->key)->startsWith('SERVICE_NAME_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ $sorted_environment_variables_preview = $sorted_environment_variables_preview->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
$ports = $this->application->main_port();
$coolify_envs = $this->generate_coolify_env_variables();
@@ -1298,7 +1388,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
- if ($this->application->environment_variables->where('key', 'PORT')->isEmpty()) {
+ if ($this->application->environment_variables->where('key', 'PORT')->isEmpty() && ! empty($ports)) {
$envs->push("PORT={$ports[0]}");
}
}
@@ -1382,6 +1472,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $envs;
}
+ private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
+ {
+ $key = str($environmentVariable->key);
+
+ return $key->startsWith('SERVICE_FQDN_')
+ || $key->startsWith('SERVICE_URL_')
+ || $key->startsWith('SERVICE_NAME_');
+ }
+
private function save_runtime_environment_variables()
{
// This method saves the .env file with ALL runtime variables
@@ -1592,15 +1691,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
+ // For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($sorted_environment_variables as $env) {
@@ -1644,15 +1742,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
+ // For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
- });
+ $sorted_environment_variables = $sorted_environment_variables->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
}
foreach ($sorted_environment_variables as $env) {
@@ -1983,7 +2080,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
- $this->build_image();
+ if ($this->application->build_pack === 'railpack') {
+ $this->build_railpack_image();
+ } else {
+ $this->build_image();
+ }
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@@ -2028,21 +2129,23 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
+ instant_remote_process(["mkdir -p {$this->serverUserHomeDir}/.docker/buildx"], $this->server);
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets();
+ $buildxMetadataVolume = "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx";
if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') {
throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
}
- $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
if ($this->dockerConfigFileExists === 'OK') {
$safeNetwork = escapeshellarg($this->destination->network);
- $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
} else {
$safeNetwork = escapeshellarg($this->destination->network);
- $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
+ $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}";
}
}
if ($firstTry) {
@@ -2147,11 +2250,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
if (isset($this->application->git_branch)) {
- $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
+ $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
}
+ private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
+ {
+ $sshCommand = "ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
+
+ if ($identityFile !== null) {
+ $sshCommand .= " -i {$identityFile}";
+ }
+
+ return 'GIT_SSH_COMMAND="'.$sshCommand.'" git ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
+ }
+
private function check_git_if_build_needed()
{
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
@@ -2197,7 +2311,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
- executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef, '/root/.ssh/id_rsa')),
'hidden' => true,
'save' => 'git_commit_sha',
]
@@ -2205,7 +2319,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
$this->execute_remote_command(
[
- executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$lsRemoteRef}"),
+ executeInDocker($this->deployment_uuid, $this->gitLsRemoteCommand($lsRemoteRef)),
'hidden' => true,
'save' => 'git_commit_sha',
],
@@ -2422,7 +2536,409 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
- private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
+ private function generate_railpack_env_variables(): Collection
+ {
+ $variables = $this->railpack_build_variables();
+
+ $this->env_railpack_args = $variables
+ ->map(function ($value, $key) {
+ return '--env '.escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ');
+
+ return $variables;
+ }
+
+ private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
+ {
+ $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
+ if (is_null($resolvedValue) || $resolvedValue === '') {
+ return null;
+ }
+
+ if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
+ return trim($resolvedValue, "'");
+ }
+
+ return $resolvedValue;
+ }
+
+ /**
+ * All buildtime variables that must reach the Railpack build.
+ *
+ * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
+ * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
+ * on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
+ * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret`
+ * channel is the only way user-defined buildtime variables become available to
+ * commands declared with `useSecrets: true`.
+ */
+ private function railpack_build_variables(): Collection
+ {
+ $genericBuildVariables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
+
+ $railpackVariables = $this->pull_request_id === 0
+ ? $this->application->railpack_environment_variables()->get()
+ : $this->application->railpack_environment_variables_preview()->get();
+
+ $variables = $genericBuildVariables
+ ->merge($railpackVariables)
+ ->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
+ $value = $this->normalize_resolved_build_variable_value($environmentVariable);
+ if (is_null($value) || $value === '') {
+ return [];
+ }
+
+ return [$environmentVariable->key => $value];
+ });
+
+ if ($this->application->install_command) {
+ $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
+ }
+
+ $variables = $this->merge_railpack_deploy_apt_packages($variables);
+
+ // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
+ // (e.g. SPAs baking the public URL) can read them via /run/secrets/.
+ foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
+ if (! is_null($value) && $value !== '') {
+ $variables->put($key, $value);
+ }
+ }
+
+ return $variables;
+ }
+
+ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
+ {
+ $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
+ ->filter()
+ ->values();
+
+ foreach (['curl', 'wget'] as $package) {
+ if (! $packages->contains($package)) {
+ $packages->push($package);
+ }
+ }
+
+ $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
+
+ return $variables;
+ }
+
+ private function railpack_build_environment_prefix(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return 'env '.$variables
+ ->map(function ($value, $key) {
+ return escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ').' ';
+ }
+
+ private function railpack_build_secret_flags(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return ' '.$variables
+ ->map(function ($value, $key) {
+ return '--secret '.escapeShellValue("id={$key},env={$key}");
+ })
+ ->implode(' ');
+ }
+
+ private function railpack_build_command(string $imageName, Collection $variables): string
+ {
+ $cacheArgs = '';
+ if ($this->force_rebuild) {
+ $cacheArgs = '--no-cache';
+ } else {
+ $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
+ }
+
+ if ($variables->isNotEmpty()) {
+ $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
+ }
+
+ $environmentPrefix = $this->railpack_build_environment_prefix($variables);
+ $secretFlags = $this->railpack_build_secret_flags($variables);
+ $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
+
+ return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
+ ." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
+ ." {$this->addHosts} --network host"
+ ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
+ ." {$cacheArgs}"
+ ."{$secretFlags}"
+ .' -f /artifacts/railpack-plan.json'
+ .' --progress plain'
+ .' --load'
+ ." -t {$imageName}"
+ ." {$this->workdir}";
+ }
+
+ private function decode_railpack_config(string $config, string $source): array
+ {
+ try {
+ $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ if (! is_array($decoded)) {
+ throw new DeploymentException("Invalid {$source}: expected a JSON object.");
+ }
+
+ return $decoded;
+ }
+
+ private function is_assoc_array(array $value): bool
+ {
+ if ($value === []) {
+ return false;
+ }
+
+ return array_keys($value) !== range(0, count($value) - 1);
+ }
+
+ private function merge_railpack_config(array $base, array $overrides): array
+ {
+ foreach ($overrides as $key => $value) {
+ if (
+ array_key_exists($key, $base)
+ && is_array($base[$key])
+ && is_array($value)
+ && $this->is_assoc_array($base[$key])
+ && $this->is_assoc_array($value)
+ ) {
+ $base[$key] = $this->merge_railpack_config($base[$key], $value);
+ } else {
+ $base[$key] = $value;
+ }
+ }
+
+ return $base;
+ }
+
+ private function railpack_config_overrides(): array
+ {
+ return [];
+ }
+
+ private function generated_railpack_config_relative_path(): string
+ {
+ return self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generated_railpack_config_absolute_path(): string
+ {
+ return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generate_railpack_config_file(): ?string
+ {
+ $repositoryConfig = [];
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
+ 'hidden' => true,
+ 'save' => 'railpack_config_exists',
+ ]);
+
+ if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
+ 'hidden' => true,
+ 'save' => 'railpack_repository_config',
+ ]);
+
+ $repositoryConfig = $this->decode_railpack_config(
+ $this->saved_outputs->get('railpack_repository_config', ''),
+ 'repository railpack.json'
+ );
+ }
+
+ $overrides = $this->railpack_config_overrides();
+ if ($repositoryConfig === [] && $overrides === []) {
+ return null;
+ }
+
+ $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
+ if (! array_key_exists('$schema', $mergedConfig)) {
+ $mergedConfig['$schema'] = 'https://schema.railpack.com';
+ }
+
+ try {
+ $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ $configPath = $this->generated_railpack_config_absolute_path();
+ $encodedConfig = base64_encode($encodedConfig);
+
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
+ 'hidden' => true,
+ ]
+ );
+
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
+ }
+
+ return $this->generated_railpack_config_relative_path();
+ }
+
+ private function railpack_prepare_command(?string $configFilePath = null): string
+ {
+ $prepare_command = 'railpack prepare';
+
+ if ($this->application->build_command) {
+ $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
+ }
+
+ if ($this->application->start_command) {
+ $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
+ }
+
+ if ($this->env_railpack_args) {
+ $prepare_command .= " {$this->env_railpack_args}";
+ }
+
+ if ($configFilePath) {
+ $prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
+ }
+
+ $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
+
+ return $prepare_command;
+ }
+
+ private function ensure_docker_buildx_available_for_railpack(): void
+ {
+ if ($this->dockerBuildxAvailable) {
+ return;
+ }
+
+ throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
+ }
+
+ private function build_railpack_image(): void
+ {
+ $this->ensure_docker_buildx_available_for_railpack();
+
+ $railpackVariables = $this->generate_railpack_env_variables();
+ $railpackConfigPath = $this->generate_railpack_config_file();
+
+ // Step 1: Generate build plan with railpack prepare
+ $prepare_command = $this->railpack_prepare_command($railpackConfigPath);
+
+ $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
+ 'hidden' => true,
+ 'save' => 'railpack_plan',
+ ],
+ );
+
+ $railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
+ if (! empty($railpackPlanRaw)) {
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
+ } else {
+ $parsedPlan = json_decode($railpackPlanRaw, true);
+ if (is_array($parsedPlan)) {
+ // Strip secrets array to avoid logging variable names in production.
+ unset($parsedPlan['secrets']);
+ $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
+ }
+ }
+ }
+
+ // Step 2: Build image using docker buildx with railpack frontend.
+ // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
+ $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
+ $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
+
+ $image_name = $this->application->settings->is_static
+ ? $this->build_image_name
+ : $this->production_image_name;
+
+ if ($this->application->settings->is_static && $this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+
+ $build_command = $this->railpack_build_command($image_name, $railpackVariables);
+
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ]
+ );
+
+ // Step 3: If static, copy built assets into nginx image
+ if ($this->application->settings->is_static) {
+ $this->build_railpack_static_image();
+ }
+ }
+
+ private function build_railpack_static_image(): void
+ {
+ $publishDir = trim($this->application->publish_directory, '/');
+ $publishDir = $publishDir ? "/{$publishDir}" : '';
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+WORKDIR /usr/share/nginx/html/
+LABEL coolify.deploymentId={$this->deployment_uuid}
+COPY --from={$this->build_image_name} /app{$publishDir} .
+COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ $nginx_config = $this->application->settings->is_spa
+ ? base64_encode(defaultNginxConfiguration('spa'))
+ : base64_encode(defaultNginxConfiguration());
+ }
+
+ $static_build = $this->dockerBuildkitSupported
+ ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
+ : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
+
+ $base64_static_build = base64_encode($static_build);
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ );
+ }
+
+ protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
@@ -2538,10 +3054,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
+ if ($this->build_pack === 'dockercompose') {
+ $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ }
+
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
@@ -2550,10 +3070,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
+ if ($this->build_pack === 'dockercompose') {
+ $envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
+ }
+
foreach ($envs as $env) {
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
if (! is_null($resolvedValue)) {
@@ -2614,7 +3138,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
'image' => $this->production_image_name,
'container_name' => $this->container_name,
'restart' => RESTART_MODE,
- 'expose' => $ports,
+ ...(! empty($ports) ? ['expose' => $ports] : []),
'networks' => [
$this->destination->network => [
'aliases' => array_merge(
@@ -2646,16 +3170,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// If custom_healthcheck_found is true, the Dockerfile's HEALTHCHECK will be used
// If healthcheck is disabled, no healthcheck will be added
if (! $this->application->custom_healthcheck_found && ! $this->application->isHealthcheckDisabled()) {
- $docker_compose['services'][$this->container_name]['healthcheck'] = [
- 'test' => [
- 'CMD-SHELL',
- $this->generate_healthcheck_commands(),
- ],
- 'interval' => $this->application->health_check_interval.'s',
- 'timeout' => $this->application->health_check_timeout.'s',
- 'retries' => $this->application->health_check_retries,
- 'start_period' => $this->application->health_check_start_period.'s',
- ];
+ $healthcheck_command = $this->generate_healthcheck_commands();
+ if ($healthcheck_command !== null) {
+ $docker_compose['services'][$this->container_name]['healthcheck'] = [
+ 'test' => [
+ 'CMD-SHELL',
+ $healthcheck_command,
+ ],
+ 'interval' => $this->application->health_check_interval.'s',
+ 'timeout' => $this->application->health_check_timeout.'s',
+ 'retries' => $this->application->health_check_retries,
+ 'start_period' => $this->application->health_check_start_period.'s',
+ ];
+ }
}
if (! is_null($this->application->limits_cpuset)) {
@@ -2865,7 +3392,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// HTTP type healthcheck (default)
if (! $this->application->health_check_port) {
- $health_check_port = (int) $this->application->ports_exposes_array[0];
+ if (! empty($this->application->ports_exposes_array)) {
+ $health_check_port = (int) $this->application->ports_exposes_array[0];
+ } else {
+ return null;
+ }
} else {
$health_check_port = (int) $this->application->health_check_port;
}
@@ -3075,29 +3606,28 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$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);
@@ -3310,14 +3840,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
private function graceful_shutdown_container(string $containerName, bool $skipRemove = false)
{
try {
- $timeout = isDev() ? 1 : 30;
+ $timeout = $this->application->settings->deploymentStopGracePeriodSeconds();
+
if ($skipRemove) {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
- ["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
+ ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
@@ -3631,7 +4162,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@@ -3653,7 +4184,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@@ -4257,6 +4788,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
'last_restart_type' => null,
]);
+ try {
+ $this->application->markDeploymentConfigurationApplied($this->application_deployment_queue);
+ } catch (Exception $e) {
+ \Log::warning('Failed to mark configuration as applied for deployment '.$this->deployment_uuid.': '.$e->getMessage());
+ }
+
event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) {
diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php
index 6d49bee4b..0d3029c66 100644
--- a/app/Jobs/CleanupStaleMultiplexedConnections.php
+++ b/app/Jobs/CleanupStaleMultiplexedConnections.php
@@ -9,6 +9,7 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
@@ -20,6 +21,132 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
{
$this->cleanupStaleConnections();
$this->cleanupNonExistentServerConnections();
+ $this->cleanupOrphanedSshProcesses();
+ $this->cleanupOrphanedCloudflaredProcesses();
+ }
+
+ /**
+ * Kill backgrounded ssh master processes that lost the ControlPath socket
+ * race. Such processes are not masters, so ControlPersist never reaps them
+ * and they leak memory until the container restarts. A legitimate master
+ * always owns its socket file; an orphan has none.
+ *
+ * Processes younger than the minimum age are skipped: a freshly forked
+ * master creates its socket a few milliseconds after starting, so a young
+ * process with no socket may simply be mid-establish rather than orphaned.
+ */
+ private function cleanupOrphanedSshProcesses(): void
+ {
+ $muxDir = storage_path('app/ssh/mux');
+ $minAge = (int) config('constants.ssh.mux_orphan_min_age');
+
+ foreach ($this->listProcesses() as $process) {
+ // Backgrounded ssh master: current `ssh -fN` or legacy `ssh -fNM`.
+ if (! preg_match('#(^|/)ssh -fN#', $process['args'])) {
+ continue;
+ }
+
+ // Only ever touch ssh processes pointing at Coolify's mux directory.
+ if (! preg_match('#ControlPath=('.preg_quote($muxDir, '#').'/\S+)#', $process['args'], $pathMatch)) {
+ continue;
+ }
+
+ if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) {
+ $this->reapOrphan('ssh', $process);
+ }
+ }
+ }
+
+ /**
+ * Kill orphaned `cloudflared access ssh` proxy processes. Each is spawned
+ * as the SSH ProxyCommand transport for a Cloudflare Tunnel server and must
+ * die with its parent ssh. When that ssh is killed or orphaned (e.g. a lost
+ * mux master), the cloudflared process can leak and accumulate. A legitimate
+ * proxy always has a live ssh parent; one without is safe to reap.
+ *
+ * Processes younger than the minimum age are skipped so a proxy whose parent
+ * ssh is still starting up, or a transient `ssh -O check` proxy mid-exit, is
+ * never mistaken for an orphan.
+ */
+ private function cleanupOrphanedCloudflaredProcesses(): void
+ {
+ $minAge = (int) config('constants.ssh.mux_orphan_min_age');
+ $processes = $this->listProcesses();
+
+ $sshPids = [];
+ foreach ($processes as $process) {
+ // The ssh binary itself, not `cloudflared access ssh` (space before ssh).
+ if (preg_match('#(^|/)ssh\s#', $process['args'])) {
+ $sshPids[$process['pid']] = true;
+ }
+ }
+
+ foreach ($processes as $process) {
+ // `cloudflared access ssh`, never the `cloudflared tunnel` daemon.
+ if (! str_contains($process['args'], 'cloudflared access ssh')) {
+ continue;
+ }
+
+ // Orphaned when no live ssh process is its parent.
+ if ($process['etimes'] >= $minAge && ! isset($sshPids[$process['ppid']])) {
+ $this->reapOrphan('cloudflared', $process);
+ }
+ }
+ }
+
+ /**
+ * Reap a detected orphan process. When orphan reaping is disabled (the
+ * default), the orphan is only logged — a dry-run mode that lets operators
+ * verify what would be killed before enabling it for real.
+ *
+ * @param array{pid: string, ppid: string, etimes: int, args: string} $process
+ */
+ private function reapOrphan(string $kind, array $process): void
+ {
+ if (! config('constants.ssh.mux_orphan_reap_enabled')) {
+ Log::info("Orphaned {$kind} process detected (dry-run, not killed)", [
+ 'pid' => $process['pid'],
+ 'etimes' => $process['etimes'],
+ 'command' => $process['args'],
+ ]);
+
+ return;
+ }
+
+ Process::run('kill '.escapeshellarg($process['pid']));
+ Log::info("Killed orphaned {$kind} process", [
+ 'pid' => $process['pid'],
+ 'etimes' => $process['etimes'],
+ 'command' => $process['args'],
+ ]);
+ }
+
+ /**
+ * Snapshot of running processes.
+ *
+ * @return list
+ */
+ private function listProcesses(): array
+ {
+ $ps = Process::run('ps -ww -eo pid=,ppid=,etimes=,args=');
+ if ($ps->exitCode() !== 0) {
+ return [];
+ }
+
+ $processes = [];
+ foreach (explode("\n", trim($ps->output())) as $line) {
+ if (! preg_match('/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/', $line, $matches)) {
+ continue;
+ }
+ $processes[] = [
+ 'pid' => $matches[1],
+ 'ppid' => $matches[2],
+ 'etimes' => (int) $matches[3],
+ 'args' => $matches[4],
+ ];
+ }
+
+ return $processes;
}
private function cleanupStaleConnections()
@@ -31,7 +158,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'server_not_found');
continue;
}
@@ -41,14 +168,14 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
$checkProcess = Process::run($checkCommand);
if ($checkProcess->exitCode() !== 0) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'expired');
}
}
}
@@ -62,7 +189,7 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
foreach ($muxFiles as $muxFile) {
$serverUuid = $this->extractServerUuidFromMuxFile($muxFile);
if (! in_array($serverUuid, $existingServerUuids)) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'server_does_not_exist');
}
}
}
@@ -72,11 +199,30 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
return substr($muxFile, 4);
}
- private function removeMultiplexFile($muxFile)
+ /**
+ * Close and delete a stale mux socket file. When orphan reaping is disabled
+ * (the default), the file is only logged — a dry-run mode that lets operators
+ * verify what would be removed before enabling it for real.
+ */
+ private function removeMultiplexFile(string $muxFile, string $reason): void
{
+ if (! config('constants.ssh.mux_orphan_reap_enabled')) {
+ Log::info('Stale mux file detected (dry-run, not removed)', [
+ 'file' => $muxFile,
+ 'reason' => $reason,
+ ]);
+
+ return;
+ }
+
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null";
Process::run($closeCommand);
Storage::disk('ssh-mux')->delete($muxFile);
+
+ Log::info('Removed stale mux file', [
+ 'file' => $muxFile,
+ 'reason' => $reason,
+ ]);
}
}
diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php
index 207191cbd..64e900b49 100644
--- a/app/Jobs/DatabaseBackupJob.php
+++ b/app/Jobs/DatabaseBackupJob.php
@@ -77,7 +77,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public function __construct(public ScheduledDatabaseBackup $backup)
{
- $this->onQueue('high');
+ $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600;
}
@@ -668,12 +668,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function upload_to_s3(): void
{
if (is_null($this->s3)) {
+ $previousS3StorageId = $this->backup->s3_storage_id;
+
$this->backup->update([
'save_s3' => false,
's3_storage_id' => null,
]);
- throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($this->backup->s3_storage_id ?? 'null').'). S3 backup has been disabled for this schedule.');
+ throw new \Exception('S3 storage configuration is missing or has been deleted (S3 storage ID: '.($previousS3StorageId ?? 'null').'). S3 backup has been disabled for this schedule.');
}
try {
diff --git a/app/Jobs/ProcessGithubPullRequestWebhook.php b/app/Jobs/ProcessGithubPullRequestWebhook.php
index 041cd812c..141351784 100644
--- a/app/Jobs/ProcessGithubPullRequestWebhook.php
+++ b/app/Jobs/ProcessGithubPullRequestWebhook.php
@@ -4,6 +4,7 @@ namespace App\Jobs;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@@ -17,6 +18,7 @@ use Visus\Cuid2\Cuid2;
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,11 +33,13 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
+ public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
public ?string $authorAssociation,
public string $fullName,
+ public bool $isForkPullRequest = false,
) {
$this->onQueue('high');
}
@@ -83,9 +87,23 @@ class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
return;
}
+ if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
+ return;
+ }
+
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
- $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
+ // Fork PRs carry untrusted code from a repository outside our control.
+ // GitHub's author_association cannot be trusted to gate these (it grants
+ // CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
+ // PRs are never deployed automatically when public previews are off.
+ if ($this->isForkPullRequest) {
+ return;
+ }
+
+ // Same-repo (non-fork) branch PRs require push access to the base repo,
+ // so only trusted associations are allowed to trigger a deployment.
+ $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR'];
if (! in_array($this->authorAssociation, $trustedAssociations)) {
return;
}
diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php
index b1a12ae2a..62e98934e 100644
--- a/app/Jobs/PushServerUpdateJob.php
+++ b/app/Jobs/PushServerUpdateJob.php
@@ -13,6 +13,16 @@ use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
+use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDocker;
+use App\Models\StandaloneDragonfly;
+use App\Models\StandaloneKeydb;
+use App\Models\StandaloneMariadb;
+use App\Models\StandaloneMongodb;
+use App\Models\StandaloneMysql;
+use App\Models\StandalonePostgresql;
+use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
use App\Notifications\Container\ContainerRestarted;
use App\Services\ContainerStatusAggregator;
use App\Traits\CalculatesExcludedStatus;
@@ -25,6 +35,7 @@ use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
use Laravel\Horizon\Contracts\Silenced;
class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -46,6 +57,18 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public Collection $services;
+ public Collection $applicationsById;
+
+ public Collection $previewsByKey;
+
+ public Collection $databasesByUuid;
+
+ public Collection $servicesById;
+
+ public Collection $serviceApplicationsById;
+
+ public Collection $serviceDatabasesById;
+
public Collection $allApplicationIds;
public Collection $allDatabaseUuids;
@@ -78,6 +101,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false;
+ private ?array $cachedDestinationIds = null;
+
public function middleware(): array
{
return [(new WithoutOverlapping('push-server-update-'.$this->server->uuid))->expireAfter(30)->dontRelease()];
@@ -103,6 +128,12 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->allTcpProxyUuids = collect();
$this->allServiceApplicationIds = collect();
$this->allServiceDatabaseIds = collect();
+ $this->applicationsById = collect();
+ $this->previewsByKey = collect();
+ $this->databasesByUuid = collect();
+ $this->servicesById = collect();
+ $this->serviceApplicationsById = collect();
+ $this->serviceDatabasesById = collect();
}
public function handle()
@@ -120,6 +151,16 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->allTcpProxyUuids ??= collect();
$this->allServiceApplicationIds ??= collect();
$this->allServiceDatabaseIds ??= collect();
+ $this->applicationsById ??= collect();
+ $this->previewsByKey ??= collect();
+ $this->databasesByUuid ??= collect();
+ $this->servicesById ??= collect();
+ $this->serviceApplicationsById ??= collect();
+ $this->serviceDatabasesById ??= collect();
+
+ // Eager-load relations the job touches repeatedly to avoid lazy-load queries
+ // (settings: disk threshold, isProxyShouldRun, isLogDrainEnabled; team: notifications).
+ $this->server->loadMissing(['settings', 'team']);
// TODO: Swarm is not supported yet
if (! $this->data) {
@@ -127,30 +168,40 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$data = collect($this->data);
- $this->server->sentinelHeartbeat();
-
+ // Heartbeat is updated by SentinelController on every push, before dispatch.
$this->containers = collect(data_get($data, 'containers'));
$filesystemUsageRoot = data_get($data, 'filesystem_usage_root.used_percentage');
- // Only dispatch storage check when disk percentage actually changes
+ // Only dispatch the storage check when disk usage is at/above the notification
+ // threshold AND the value changed. Below the threshold ServerStorageCheckJob
+ // has nothing to do (it only sends a HighDiskUsage notification), so dispatching
+ // it is wasted work — and most servers sit well below the threshold.
+ $diskThreshold = data_get($this->server, 'settings.server_disk_usage_notification_threshold', 80);
$storageCacheKey = 'storage-check:'.$this->server->id;
$lastPercentage = Cache::get($storageCacheKey);
- if ($lastPercentage === null || (string) $lastPercentage !== (string) $filesystemUsageRoot) {
+ if ($filesystemUsageRoot !== null
+ && $filesystemUsageRoot >= $diskThreshold
+ && (string) $lastPercentage !== (string) $filesystemUsageRoot) {
Cache::put($storageCacheKey, $filesystemUsageRoot, 600);
ServerStorageCheckJob::dispatch($this->server, $filesystemUsageRoot);
+ } elseif ($filesystemUsageRoot !== null && $filesystemUsageRoot < $diskThreshold) {
+ Cache::forget($storageCacheKey);
}
if ($this->containers->isEmpty()) {
return;
}
- $this->applications = $this->server->applications();
- $this->databases = $this->server->databases();
- $this->previews = $this->server->previews();
- // Eager load service applications and databases to avoid N+1 queries
- $this->services = $this->server->services()
- ->with(['applications:id,service_id', 'databases:id,service_id'])
- ->get();
+ $this->applications = $this->loadApplications();
+ $this->databases = $this->loadDatabases();
+ $this->previews = $this->loadPreviews();
+ $this->services = $this->loadServices();
+ $this->applicationsById = $this->applications->keyBy(fn ($application) => (string) $application->id);
+ $this->previewsByKey = $this->previews->keyBy(fn ($preview) => $preview->application_id.':'.$preview->pull_request_id);
+ $this->databasesByUuid = $this->databases->keyBy('uuid');
+ $this->servicesById = $this->services->keyBy(fn ($service) => (string) $service->id);
+ $this->serviceApplicationsById = $this->services->flatMap(fn ($service) => $service->applications)->keyBy(fn ($application) => (string) $application->id);
+ $this->serviceDatabasesById = $this->services->flatMap(fn ($service) => $service->databases)->keyBy(fn ($database) => (string) $database->id);
$this->allApplicationIds = $this->applications->filter(function ($application) {
return $application->additional_servers_count === 0;
@@ -163,9 +214,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
});
$this->allDatabaseUuids = $this->databases->pluck('uuid');
$this->allTcpProxyUuids = $this->databases->where('is_public', true)->pluck('uuid');
- // Use eager-loaded relationships instead of querying in loop
- $this->allServiceApplicationIds = $this->services->flatMap(fn ($service) => $service->applications->pluck('id'));
- $this->allServiceDatabaseIds = $this->services->flatMap(fn ($service) => $service->databases->pluck('id'));
+ $this->allServiceApplicationIds = $this->serviceApplicationsById->keys();
+ $this->allServiceDatabaseIds = $this->serviceDatabasesById->keys();
foreach ($this->containers as $container) {
$containerStatus = data_get($container, 'state', 'exited');
@@ -279,6 +329,151 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->checkLogDrainContainer();
}
+ private function loadApplications(): Collection
+ {
+ [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
+
+ $applications = ($standaloneDockerIds->isNotEmpty() || $swarmDockerIds->isNotEmpty())
+ ? Application::withoutGlobalScope('withRelations')
+ ->select([
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'build_pack',
+ 'docker_compose_raw',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ ])
+ ->withCount('additional_servers')
+ ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
+ ->get()
+ : collect();
+
+ $additionalApplicationIds = DB::table('additional_destinations')
+ ->where('server_id', $this->server->id)
+ ->pluck('application_id');
+
+ if ($additionalApplicationIds->isNotEmpty()) {
+ $applications = $applications->concat(
+ Application::withoutGlobalScope('withRelations')
+ ->select([
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'build_pack',
+ 'docker_compose_raw',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ ])
+ ->withCount('additional_servers')
+ ->whereIn('id', $additionalApplicationIds)
+ ->get()
+ );
+ }
+
+ return $applications->unique('id')->values();
+ }
+
+ private function loadPreviews(): Collection
+ {
+ $applicationIds = $this->applications->pluck('id');
+
+ if ($applicationIds->isEmpty()) {
+ return collect();
+ }
+
+ return ApplicationPreview::query()
+ ->select([
+ 'id',
+ 'application_id',
+ 'pull_request_id',
+ 'status',
+ 'last_online_at',
+ ])
+ ->whereIn('application_id', $applicationIds)
+ ->get();
+ }
+
+ private function loadServices(): Collection
+ {
+ return $this->server->services()
+ ->select([
+ 'id',
+ 'server_id',
+ 'uuid',
+ 'docker_compose_raw',
+ ])
+ ->with([
+ 'applications:id,service_id,status,last_online_at',
+ 'databases:id,service_id,status,last_online_at,is_public,name',
+ ])
+ ->get();
+ }
+
+ private function loadDatabases(): Collection
+ {
+ [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
+ if ($standaloneDockerIds->isEmpty() && $swarmDockerIds->isEmpty()) {
+ return collect();
+ }
+ $databaseColumns = [
+ 'id',
+ 'uuid',
+ 'name',
+ 'status',
+ 'is_public',
+ 'destination_id',
+ 'destination_type',
+ 'last_online_at',
+ 'restart_count',
+ 'last_restart_at',
+ 'last_restart_type',
+ ];
+
+ return collect([
+ StandalonePostgresql::class,
+ StandaloneRedis::class,
+ StandaloneMongodb::class,
+ StandaloneMysql::class,
+ StandaloneMariadb::class,
+ StandaloneKeydb::class,
+ StandaloneDragonfly::class,
+ StandaloneClickhouse::class,
+ ])->flatMap(function (string $databaseClass) use ($databaseColumns, $standaloneDockerIds, $swarmDockerIds) {
+ return $databaseClass::query()
+ ->select($databaseColumns)
+ ->where(fn ($query) => $this->scopeDestination($query, $standaloneDockerIds, $swarmDockerIds))
+ ->get();
+ })->filter(fn ($database) => data_get($database, 'name') !== 'coolify-db')->values();
+ }
+
+ private function serverDestinationIds(): array
+ {
+ if ($this->cachedDestinationIds !== null) {
+ return $this->cachedDestinationIds;
+ }
+
+ return $this->cachedDestinationIds = [
+ StandaloneDocker::where('server_id', $this->server->id)->pluck('id'),
+ SwarmDocker::where('server_id', $this->server->id)->pluck('id'),
+ ];
+ }
+
+ private function scopeDestination($query, Collection $standaloneDockerIds, Collection $swarmDockerIds): void
+ {
+ $query->where(function ($query) use ($standaloneDockerIds) {
+ $query->where('destination_type', StandaloneDocker::class)
+ ->whereIn('destination_id', $standaloneDockerIds);
+ })->orWhere(function ($query) use ($swarmDockerIds) {
+ $query->where('destination_type', SwarmDocker::class)
+ ->whereIn('destination_id', $swarmDockerIds);
+ });
+ }
+
private function aggregateMultiContainerStatuses()
{
if ($this->applicationContainerStatuses->isEmpty()) {
@@ -286,7 +481,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
foreach ($this->applicationContainerStatuses as $applicationId => $containerStatuses) {
- $application = $this->applications->where('id', $applicationId)->first();
+ $application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
continue;
}
@@ -307,8 +502,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
continue;
@@ -323,8 +516,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
}
}
@@ -343,7 +534,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
continue;
}
- $service = $this->services->where('id', $serviceId)->first();
+ $service = $this->servicesById->get((string) $serviceId);
if (! $service) {
continue;
}
@@ -351,9 +542,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
- $subResource = $service->applications->where('id', $subId)->first();
+ $subResource = $this->serviceApplicationsById->get((string) $subId);
} elseif ($subType === 'database') {
- $subResource = $service->databases->where('id', $subId)->first();
+ $subResource = $this->serviceDatabasesById->get((string) $subId);
}
if (! $subResource) {
@@ -375,8 +566,6 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
- } elseif ($aggregatedStatus) {
- $subResource->update(['last_online_at' => now()]);
}
continue;
@@ -392,39 +581,31 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
- } elseif ($aggregatedStatus) {
- $subResource->update(['last_online_at' => now()]);
}
}
}
private function updateApplicationStatus(string $applicationId, string $containerStatus)
{
- $application = $this->applications->where('id', $applicationId)->first();
+ $application = $this->applicationsById->get((string) $applicationId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
- } else {
- $application->update(['last_online_at' => now()]);
}
}
private function updateApplicationPreviewStatus(string $applicationId, string $pullRequestId, string $containerStatus)
{
- $application = $this->previews->where('application_id', $applicationId)
- ->where('pull_request_id', $pullRequestId)
- ->first();
+ $application = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if (! $application) {
return;
}
if ($application->status !== $containerStatus) {
$application->status = $containerStatus;
$application->save();
- } else {
- $application->update(['last_online_at' => now()]);
}
}
@@ -472,9 +653,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$applicationId = $parts[0];
$pullRequestId = $parts[1];
- $applicationPreview = $this->previews->where('application_id', $applicationId)
- ->where('pull_request_id', $pullRequestId)
- ->first();
+ $applicationPreview = $this->previewsByKey->get($applicationId.':'.$pullRequestId);
if ($applicationPreview && ! str($applicationPreview->status)->startsWith('exited')) {
$previewIdsToUpdate->push($applicationPreview->id);
@@ -500,11 +679,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
} catch (\Throwable $e) {
}
} else {
- // Connect proxy to networks periodically (every 10 min) to avoid excessive job dispatches.
+ // Connect proxy to networks periodically as a safety net to avoid excessive job dispatches.
// On-demand triggers (new network, service deploy) use dispatchSync() and bypass this.
$proxyCacheKey = 'connect-proxy:'.$this->server->id;
if (! Cache::has($proxyCacheKey)) {
- Cache::put($proxyCacheKey, true, 600);
+ Cache::put($proxyCacheKey, true, config('constants.proxy.connect_networks_interval_seconds', 3600));
ConnectProxyToNetworksJob::dispatch($this->server);
}
}
@@ -513,15 +692,13 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
private function updateDatabaseStatus(string $databaseUuid, string $containerStatus, bool $tcpProxy = false)
{
- $database = $this->databases->where('uuid', $databaseUuid)->first();
+ $database = $this->databasesByUuid->get($databaseUuid);
if (! $database) {
return;
}
if ($database->status !== $containerStatus) {
$database->status = $containerStatus;
$database->save();
- } else {
- $database->update(['last_online_at' => now()]);
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
@@ -556,7 +733,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
$notFoundDatabaseUuids->each(function ($databaseUuid) {
- $database = $this->databases->where('uuid', $databaseUuid)->first();
+ $database = $this->databasesByUuid->get($databaseUuid);
if ($database) {
if (! str($database->status)->startsWith('exited')) {
$database->update([
diff --git a/app/Jobs/ScheduledJobManager.php b/app/Jobs/ScheduledJobManager.php
index 71829ea41..e7a21949c 100644
--- a/app/Jobs/ScheduledJobManager.php
+++ b/app/Jobs/ScheduledJobManager.php
@@ -6,14 +6,15 @@ use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\Server;
use App\Models\Team;
+use Cron\CronExpression;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
-use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
@@ -22,6 +23,8 @@ class ScheduledJobManager implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
+ private const CHUNK_SIZE = 100;
+
/**
* The time when this job execution started.
* Used to ensure all scheduled items are evaluated against the same point in time.
@@ -37,17 +40,7 @@ class ScheduledJobManager implements ShouldQueue
*/
public function __construct()
{
- $this->onQueue($this->determineQueue());
- }
-
- private function determineQueue(): string
- {
- $preferredQueue = 'crons';
- $fallbackQueue = 'high';
-
- $configuredQueues = explode(',', env('HORIZON_QUEUES', 'high,default'));
-
- return in_array($preferredQueue, $configuredQueues) ? $preferredQueue : $fallbackQueue;
+ $this->onQueue(crons_queue());
}
/**
@@ -106,21 +99,11 @@ class ScheduledJobManager implements ShouldQueue
'execution_time' => $this->executionTime->toIso8601String(),
]);
- // Process backups - don't let failures stop task processing
+ // Process scheduled backups and tasks together so neither type starves the other.
try {
- $this->processScheduledBackups();
+ $this->processScheduledBackupsAndTasks();
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Failed to process scheduled backups', [
- 'error' => $e->getMessage(),
- 'trace' => $e->getTraceAsString(),
- ]);
- }
-
- // Process tasks - don't let failures stop the job manager
- try {
- $this->processScheduledTasks();
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Failed to process scheduled tasks', [
+ Log::channel('scheduled-errors')->error('Failed to process scheduled backups and tasks', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
@@ -151,125 +134,211 @@ class ScheduledJobManager implements ShouldQueue
}
}
- private function processScheduledBackups(): void
+ private function processScheduledBackupsAndTasks(): void
{
- $backups = ScheduledDatabaseBackup::with(['database'])
+ $lastBackupId = 0;
+ $lastTaskId = 0;
+
+ do {
+ $backups = $this->scheduledBackupQuery($lastBackupId)->get();
+ $tasks = $this->scheduledTaskQuery($lastTaskId)->get();
+
+ if ($backups->isNotEmpty()) {
+ $lastBackupId = $backups->last()->id;
+ }
+
+ if ($tasks->isNotEmpty()) {
+ $lastTaskId = $tasks->last()->id;
+ }
+
+ $this->processInterleavedDueSchedules(
+ $this->dueScheduledBackups($backups),
+ $this->dueScheduledTasks($tasks),
+ );
+ } while ($backups->isNotEmpty() || $tasks->isNotEmpty());
+ }
+
+ /**
+ * @param array $dueBackups
+ * @param array $dueTasks
+ */
+ private function processInterleavedDueSchedules(array $dueBackups, array $dueTasks): void
+ {
+ $maxCount = max(count($dueBackups), count($dueTasks));
+
+ for ($index = 0; $index < $maxCount; $index++) {
+ if (isset($dueBackups[$index])) {
+ $this->processScheduledBackup($dueBackups[$index]['backup'], $dueBackups[$index]['server']);
+ }
+
+ if (isset($dueTasks[$index])) {
+ $this->processScheduledTask($dueTasks[$index]['task'], $dueTasks[$index]['server']);
+ }
+ }
+ }
+
+ private function scheduledBackupQuery(int $lastBackupId): Builder
+ {
+ return ScheduledDatabaseBackup::with(['database', 'team.subscription'])
->where('enabled', true)
- ->get();
+ ->where('id', '>', $lastBackupId)
+ ->orderBy('id')
+ ->limit(self::CHUNK_SIZE);
+ }
+
+ private function scheduledTaskQuery(int $lastTaskId): Builder
+ {
+ return ScheduledTask::with([
+ 'service.destination.server.settings',
+ 'service.destination.server.team.subscription',
+ 'application.destination.server.settings',
+ 'application.destination.server.team.subscription',
+ ])
+ ->where('enabled', true)
+ ->where('id', '>', $lastTaskId)
+ ->orderBy('id')
+ ->limit(self::CHUNK_SIZE);
+ }
+
+ /**
+ * @param iterable $backups
+ * @return array
+ */
+ private function dueScheduledBackups(iterable $backups): array
+ {
+ $dueBackups = [];
foreach ($backups as $backup) {
try {
$server = $backup->server();
- $skipReason = $this->getBackupSkipReason($backup, $server);
- if ($skipReason !== null) {
- $this->skippedCount++;
- $this->logSkip('backup', $skipReason, [
- 'backup_id' => $backup->id,
- 'database_id' => $backup->database_id,
- 'database_type' => $backup->database_type,
- 'team_id' => $backup->team_id ?? null,
- ]);
+
+ if (blank(data_get($backup, 'database')) || blank($server)) {
+ $this->processScheduledBackup($backup, $server);
continue;
}
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
-
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
- $frequency = $backup->frequency;
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- if (shouldRunCronNow($frequency, $serverTimezone, "scheduled-backup:{$backup->id}", $this->executionTime)) {
- DatabaseBackupJob::dispatch($backup);
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Backup dispatched', [
- 'backup_id' => $backup->id,
- 'database_id' => $backup->database_id,
- 'database_type' => $backup->database_type,
- 'team_id' => $backup->team_id ?? null,
- 'server_id' => $server->id,
- ]);
+ if ($this->isDueCandidateBeforeExpensiveChecks($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
+ $dueBackups[] = [
+ 'backup' => $backup,
+ 'server' => $server,
+ ];
}
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing backup', [
+ Log::channel('scheduled-errors')->error('Error prechecking backup', [
'backup_id' => $backup->id,
'error' => $e->getMessage(),
]);
}
}
+
+ return $dueBackups;
}
- private function processScheduledTasks(): void
+ /**
+ * @param iterable $tasks
+ * @return array
+ */
+ private function dueScheduledTasks(iterable $tasks): array
{
- $tasks = ScheduledTask::with(['service', 'application'])
- ->where('enabled', true)
- ->get();
+ $dueTasks = [];
foreach ($tasks as $task) {
try {
$server = $task->server();
- // Phase 1: Critical checks (always — cheap, handles orphans and infra issues)
- $criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
- if ($criticalSkip !== null) {
- $this->skippedCount++;
- $this->logSkip('task', $criticalSkip, [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server?->team_id,
- ]);
+ if (blank($server) || (! $task->service && ! $task->application)) {
+ $this->processScheduledTask($task, $server);
continue;
}
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
-
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
+ if ($this->isDueCandidateBeforeExpensiveChecks($task->frequency, $server, "scheduled-task:{$task->id}")) {
+ $dueTasks[] = [
+ 'task' => $task,
+ 'server' => $server,
+ ];
}
-
- $frequency = $task->frequency;
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- if (! shouldRunCronNow($frequency, $serverTimezone, "scheduled-task:{$task->id}", $this->executionTime)) {
- continue;
- }
-
- // Phase 2: Runtime checks (only when cron is due — avoids noise for stopped resources)
- $runtimeSkip = $this->getTaskRuntimeSkipReason($task);
- if ($runtimeSkip !== null) {
- $this->skippedCount++;
- $this->logSkip('task', $runtimeSkip, [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server->team_id,
- ]);
-
- continue;
- }
-
- ScheduledTaskJob::dispatch($task);
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Task dispatched', [
- 'task_id' => $task->id,
- 'task_name' => $task->name,
- 'team_id' => $server->team_id,
- 'server_id' => $server->id,
- ]);
} catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing task', [
+ Log::channel('scheduled-errors')->error('Error prechecking task', [
'task_id' => $task->id,
'error' => $e->getMessage(),
]);
}
}
+
+ return $dueTasks;
+ }
+
+ private function processScheduledBackup(ScheduledDatabaseBackup $backup, ?Server $precheckedServer = null): void
+ {
+ try {
+ $server = $precheckedServer ?? $backup->server();
+ $skipReason = $this->getBackupSkipReason($backup, $server);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logBackupSkip($backup, $skipReason);
+
+ return;
+ }
+
+ if ($this->shouldDispatch($backup->frequency, $server, "scheduled-backup:{$backup->id}")) {
+ DatabaseBackupJob::dispatch($backup);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Backup dispatched', [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ 'server_id' => $server->id,
+ ]);
+ }
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing backup', [
+ 'backup_id' => $backup->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ private function processScheduledTask(ScheduledTask $task, ?Server $precheckedServer = null): void
+ {
+ try {
+ $server = $precheckedServer ?? $task->server();
+ $criticalSkip = $this->getTaskCriticalSkipReason($task, $server);
+ if ($criticalSkip !== null) {
+ $this->skippedCount++;
+ $this->logTaskSkip($task, $criticalSkip, $server);
+
+ return;
+ }
+
+ if (! $this->shouldDispatch($task->frequency, $server, "scheduled-task:{$task->id}")) {
+ return;
+ }
+
+ $runtimeSkip = $this->getTaskRuntimeSkipReason($task);
+ if ($runtimeSkip !== null) {
+ $this->skippedCount++;
+ $this->logTaskSkip($task, $runtimeSkip, $server);
+
+ return;
+ }
+
+ ScheduledTaskJob::dispatch($task);
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Task dispatched', [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server->team_id,
+ 'server_id' => $server->id,
+ ]);
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing task', [
+ 'task_id' => $task->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
}
private function getBackupSkipReason(ScheduledDatabaseBackup $backup, ?Server $server): ?string
@@ -337,71 +406,70 @@ class ScheduledJobManager implements ShouldQueue
private function processDockerCleanups(): void
{
- // Get all servers that need cleanup checks
- $servers = $this->getServersForCleanup();
-
- foreach ($servers as $server) {
- try {
- $skipReason = $this->getDockerCleanupSkipReason($server);
- if ($skipReason !== null) {
- $this->skippedCount++;
- $this->logSkip('docker_cleanup', $skipReason, [
- 'server_id' => $server->id,
- 'server_name' => $server->name,
- 'team_id' => $server->team_id,
- ]);
-
- continue;
+ $this->getServersForCleanupQuery()
+ ->chunkById(self::CHUNK_SIZE, function ($servers): void {
+ foreach ($servers as $server) {
+ $this->processDockerCleanup($server);
}
+ });
+ }
- $serverTimezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
- if (validate_timezone($serverTimezone) === false) {
- $serverTimezone = config('app.timezone');
- }
-
- $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
- if (isset(VALID_CRON_STRINGS[$frequency])) {
- $frequency = VALID_CRON_STRINGS[$frequency];
- }
-
- // Use the frozen execution time for consistent evaluation
- if (shouldRunCronNow($frequency, $serverTimezone, "docker-cleanup:{$server->id}", $this->executionTime)) {
- DockerCleanupJob::dispatch(
- $server,
- false,
- $server->settings->delete_unused_volumes,
- $server->settings->delete_unused_networks
- );
- $this->dispatchedCount++;
- Log::channel('scheduled')->info('Docker cleanup dispatched', [
- 'server_id' => $server->id,
- 'server_name' => $server->name,
- 'team_id' => $server->team_id,
- ]);
- }
- } catch (\Exception $e) {
- Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
+ private function processDockerCleanup(Server $server): void
+ {
+ try {
+ $skipReason = $this->getDockerCleanupSkipReason($server);
+ if ($skipReason !== null) {
+ $this->skippedCount++;
+ $this->logSkip('docker_cleanup', $skipReason, [
'server_id' => $server->id,
'server_name' => $server->name,
- 'error' => $e->getMessage(),
+ 'team_id' => $server->team_id,
+ ]);
+
+ return;
+ }
+
+ $frequency = data_get($server->settings, 'docker_cleanup_frequency', '0 * * * *');
+
+ if ($this->shouldDispatch($frequency, $server, "docker-cleanup:{$server->id}")) {
+ DockerCleanupJob::dispatch(
+ $server,
+ false,
+ $server->settings->delete_unused_volumes,
+ $server->settings->delete_unused_networks
+ );
+ $this->dispatchedCount++;
+ Log::channel('scheduled')->info('Docker cleanup dispatched', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'team_id' => $server->team_id,
]);
}
+ } catch (\Exception $e) {
+ Log::channel('scheduled-errors')->error('Error processing docker cleanup', [
+ 'server_id' => $server->id,
+ 'server_name' => $server->name,
+ 'error' => $e->getMessage(),
+ ]);
}
}
- private function getServersForCleanup(): Collection
+ private function getServersForCleanupQuery(): Builder
{
$query = Server::with('settings')
->where('ip', '!=', '1.2.3.4');
if (isCloud()) {
- $servers = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
- $own = Team::find(0)->servers()->with('settings')->get();
-
- return $servers->merge($own);
+ $query
+ ->with('team.subscription')
+ ->where(function (Builder $query): void {
+ $query
+ ->where('team_id', 0)
+ ->orWhereRelation('team.subscription', 'stripe_invoice_paid', true);
+ });
}
- return $query->get();
+ return $query;
}
private function getDockerCleanupSkipReason(Server $server): ?string
@@ -428,4 +496,71 @@ class ScheduledJobManager implements ShouldQueue
'execution_time' => $this->executionTime?->toIso8601String(),
], $context));
}
+
+ private function shouldDispatch(string $frequency, Server $server, string $dedupKey): bool
+ {
+ return shouldRunCronNow(
+ $this->normalizeFrequency($frequency),
+ $this->serverTimezone($server),
+ $dedupKey,
+ $this->executionTime,
+ );
+ }
+
+ private function isDueCandidateBeforeExpensiveChecks(string $frequency, Server $server, string $dedupKey): bool
+ {
+ $cron = new CronExpression($this->normalizeFrequency($frequency));
+ $executionTime = ($this->executionTime ?? Carbon::now())->copy()->setTimezone($this->serverTimezone($server));
+ $lastDispatched = Cache::get($dedupKey);
+ $previousDue = Carbon::instance($cron->getPreviousRunDate($executionTime, allowCurrentDate: true));
+
+ if ($lastDispatched === null) {
+ $isDue = $cron->isDue($executionTime);
+
+ if (! $isDue) {
+ Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
+ }
+
+ return $isDue;
+ }
+
+ $shouldFire = $previousDue->gt(Carbon::parse($lastDispatched));
+
+ if (! $shouldFire) {
+ Cache::put($dedupKey, $previousDue->toIso8601String(), 2592000);
+ }
+
+ return $shouldFire;
+ }
+
+ private function normalizeFrequency(string $frequency): string
+ {
+ return VALID_CRON_STRINGS[$frequency] ?? $frequency;
+ }
+
+ private function serverTimezone(Server $server): string
+ {
+ $timezone = data_get($server->settings, 'server_timezone', config('app.timezone'));
+
+ return validate_timezone($timezone) ? $timezone : config('app.timezone');
+ }
+
+ private function logBackupSkip(ScheduledDatabaseBackup $backup, string $reason): void
+ {
+ $this->logSkip('backup', $reason, [
+ 'backup_id' => $backup->id,
+ 'database_id' => $backup->database_id,
+ 'database_type' => $backup->database_type,
+ 'team_id' => $backup->team_id ?? null,
+ ]);
+ }
+
+ private function logTaskSkip(ScheduledTask $task, string $reason, ?Server $server): void
+ {
+ $this->logSkip('task', $reason, [
+ 'task_id' => $task->id,
+ 'task_name' => $task->name,
+ 'team_id' => $server?->team_id,
+ ]);
+ }
}
diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php
index 49b9b9702..dc11ec89e 100644
--- a/app/Jobs/ScheduledTaskJob.php
+++ b/app/Jobs/ScheduledTaskJob.php
@@ -40,13 +40,13 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/
public $timeout = 300;
- public Team $team;
+ public ?Team $team = null;
public ?Server $server = null;
public ScheduledTask $task;
- public Application|Service $resource;
+ public Application|Service|null $resource = null;
public ?ScheduledTaskExecution $task_log = null;
@@ -61,25 +61,34 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
public array $containers = [];
- public string $server_timezone;
+ public string $server_timezone = 'UTC';
- public function __construct($task)
+ public function __construct(ScheduledTask $task)
{
- $this->onQueue('high');
+ $this->onQueue(crons_queue());
$this->task = $task;
- if ($service = $task->service()->first()) {
- $this->resource = $service;
- } elseif ($application = $task->application()->first()) {
- $this->resource = $application;
+ $this->timeout = $this->task->timeout ?? 300;
+ }
+
+ private function initializeExecutionContext(): void
+ {
+ $this->task->loadMissing([
+ 'service.destination.server.settings',
+ 'application.destination.server.settings',
+ ]);
+
+ if ($this->task->service) {
+ $this->resource = $this->task->service;
+ } elseif ($this->task->application) {
+ $this->resource = $this->task->application;
} else {
throw new \RuntimeException('ScheduledTaskJob failed: No resource found.');
}
- $this->team = Team::findOrFail($task->team_id);
- $this->server_timezone = $this->getServerTimezone();
- // Set timeout from task configuration
- $this->timeout = $this->task->timeout ?? 300;
+ $this->team = Team::findOrFail($this->task->team_id);
+ $this->server_timezone = $this->getServerTimezone();
+ $this->server = $this->resource->destination->server;
}
private function getServerTimezone(): string
@@ -98,6 +107,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
$startTime = Carbon::now();
try {
+ $this->initializeExecutionContext();
+
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
@@ -107,8 +118,6 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
// Store execution ID for timeout handling
$this->executionId = $this->task_log->id;
- $this->server = $this->resource->destination->server;
-
if ($this->resource->type() === 'application') {
$containers = getCurrentApplicationContainerStatus($this->server, $this->resource->id, 0);
if ($containers->count() > 0) {
@@ -179,7 +188,10 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
// Re-throw to trigger Laravel's retry mechanism with backoff
throw $e;
} finally {
- ScheduledTaskDone::dispatch($this->team->id);
+ if ($this->team) {
+ ScheduledTaskDone::dispatch($this->team->id);
+ }
+
if ($this->task_log) {
$finishedAt = Carbon::now();
$duration = round($startTime->floatDiffInSeconds($finishedAt), 2);
@@ -205,6 +217,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
*/
public function failed(?\Throwable $exception): void
{
+ $this->team ??= Team::find($this->task->team_id);
+
Log::channel('scheduled-errors')->error('ScheduledTask permanently failed', [
'job' => 'ScheduledTaskJob',
'task_id' => $this->task->uuid,
diff --git a/app/Jobs/SendWebhookJob.php b/app/Jobs/SendWebhookJob.php
index 9d2a94606..17517cebb 100644
--- a/app/Jobs/SendWebhookJob.php
+++ b/app/Jobs/SendWebhookJob.php
@@ -2,6 +2,7 @@
namespace App\Jobs;
+use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -44,7 +45,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
- ['webhook_url' => ['required', 'url', new \App\Rules\SafeWebhookUrl]]
+ ['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
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 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
public function handle()
{
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@@ -84,6 +88,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -99,6 +105,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
$this->server->update(['unreachable_count' => 0]);
}
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -111,6 +119,8 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
}
@@ -118,17 +128,41 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
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\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
+use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -35,7 +36,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@@ -94,12 +95,12 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_subscription_id) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@@ -154,7 +155,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
$team = data_get($subscription, 'team');
if (! $team) {
@@ -165,7 +166,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@@ -190,7 +191,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_invoice_paid) {
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
@@ -334,7 +335,7 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
break;
default:
diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php
index f37a9704e..060ec3ac6 100644
--- a/app/Jobs/VolumeCloneJob.php
+++ b/app/Jobs/VolumeCloneJob.php
@@ -43,27 +43,34 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
protected function cloneLocalVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
+
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer);
}
protected function cloneRemoteVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
+ $srcDir = escapeshellarg($sourceCloneDir);
+ $tgtDir = escapeshellarg($targetCloneDir);
try {
instant_remote_process([
- "mkdir -p $sourceCloneDir",
- "chmod 777 $sourceCloneDir",
- "docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
+ "mkdir -p {$srcDir}",
+ "chmod 777 {$srcDir}",
+ "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer);
instant_remote_process([
- "mkdir -p $targetCloneDir",
- "chmod 777 $targetCloneDir",
+ "mkdir -p {$tgtDir}",
+ "chmod 777 {$tgtDir}",
], $this->targetServer);
instant_scp(
@@ -74,8 +81,8 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
);
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer);
} catch (\Exception $e) {
@@ -84,7 +91,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
} finally {
try {
instant_remote_process([
- "rm -rf $sourceCloneDir",
+ "rm -rf {$srcDir}",
], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
@@ -93,7 +100,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
try {
if ($this->targetServer) {
instant_remote_process([
- "rm -rf $targetCloneDir",
+ "rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php
index d1345e7bf..4d22047cc 100644
--- a/app/Livewire/Admin/Index.php
+++ b/app/Livewire/Admin/Index.php
@@ -37,7 +37,7 @@ class Index extends Component
Auth::login($user);
refreshSession($team_to_switch_to);
- return redirect(request()->header('Referer'));
+ return redirect()->route('admin.index');
}
}
@@ -70,7 +70,7 @@ class Index extends Component
Auth::login($user);
refreshSession($team_to_switch_to);
- return redirect(request()->header('Referer'));
+ return redirect()->route('dashboard');
}
private function authorizeAdminAccess(): void
diff --git a/app/Livewire/Destination/Index.php b/app/Livewire/Destination/Index.php
index a3df3fd56..7a4b89fab 100644
--- a/app/Livewire/Destination/Index.php
+++ b/app/Livewire/Destination/Index.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Destination;
use App\Models\Server;
+use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -11,9 +12,15 @@ class Index extends Component
#[Locked]
public $servers;
- public function mount()
+ #[Locked]
+ public Collection $destinations;
+
+ public function mount(): void
{
$this->servers = Server::isUsable()->get();
+ $this->destinations = $this->servers
+ ->flatMap(fn (Server $server) => $server->standaloneDockers->concat($server->swarmDockers))
+ ->values();
}
public function render()
diff --git a/app/Livewire/Destination/New/Docker.php b/app/Livewire/Destination/New/Docker.php
index 6f9b6f995..254823163 100644
--- a/app/Livewire/Destination/New/Docker.php
+++ b/app/Livewire/Destination/New/Docker.php
@@ -33,44 +33,49 @@ class Docker extends Component
#[Validate(['required', 'boolean'])]
public bool $isSwarm = false;
- public function mount(?string $server_id = null)
+ public function mount(?string $server_id = null): void
{
- $this->network = new Cuid2;
+ $this->network = (string) new Cuid2;
$this->servers = Server::isUsable()->get();
- if ($server_id) {
- $foundServer = $this->servers->find($server_id) ?: $this->servers->first();
- if (! $foundServer) {
- throw new \Exception('Server not found.');
+
+ if (filled($server_id)) {
+ $this->selectedServer = Server::ownedByCurrentTeam()->whereKey($server_id)->firstOrFail();
+
+ if (! $this->servers->contains('id', $this->selectedServer->id)) {
+ $this->servers->push($this->selectedServer);
}
- $this->selectedServer = $foundServer;
- $this->serverId = $this->selectedServer->id;
+
+ $this->serverId = (string) $this->selectedServer->id;
} else {
$foundServer = $this->servers->first();
if (! $foundServer) {
throw new \Exception('Server not found.');
}
$this->selectedServer = $foundServer;
- $this->serverId = $this->selectedServer->id;
+ $this->serverId = (string) $this->selectedServer->id;
}
$this->generateName();
}
- public function updatedServerId()
+ public function updatedServerId(): void
{
$this->selectedServer = $this->servers->find($this->serverId);
+ if (! $this->selectedServer) {
+ throw new \Exception('Server not found.');
+ }
$this->generateName();
}
- public function generateName()
+ public function generateName(): void
{
$name = data_get($this->selectedServer, 'name', new Cuid2);
$this->name = str("{$name}-{$this->network}")->kebab();
}
- public function submit()
+ public function submit(): mixed
{
try {
- $this->authorize('create', StandaloneDocker::class);
+ $this->authorize('create', $this->isSwarm ? SwarmDocker::class : StandaloneDocker::class);
$this->validate();
if ($this->isSwarm) {
$found = $this->selectedServer->swarmDockers()->where('network', $this->network)->first();
diff --git a/app/Livewire/Destination/Resources.php b/app/Livewire/Destination/Resources.php
new file mode 100644
index 000000000..c71010411
--- /dev/null
+++ b/app/Livewire/Destination/Resources.php
@@ -0,0 +1,125 @@
+route('destination.index');
+ }
+ if (! $destination instanceof StandaloneDocker) {
+ return redirect()->route('destination.show', ['destination_uuid' => $destination->uuid]);
+ }
+
+ $this->destination = $destination;
+ $this->loadResources();
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ /**
+ * Load applications, services, and database resources deployed to the standalone Docker destination.
+ *
+ * @return void Populates the resources property for display.
+ */
+ public function loadResources(): void
+ {
+ $this->resources = $this->collectResources([
+ $this->destination->applications,
+ $this->destination->services,
+ $this->destination->postgresqls,
+ $this->destination->redis,
+ $this->destination->mongodbs,
+ $this->destination->mysqls,
+ $this->destination->mariadbs,
+ $this->destination->keydbs,
+ $this->destination->dragonflies,
+ $this->destination->clickhouses,
+ ]);
+ }
+
+ /**
+ * @param array> $groups
+ * @return array
+ */
+ protected function collectResources(array $groups): array
+ {
+ $rows = [];
+ foreach ($groups as $group) {
+ foreach ($group as $resource) {
+ $rows[] = $this->resourceRow($resource);
+ }
+ }
+
+ return $rows;
+ }
+
+ /**
+ * @param Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource
+ * @return array{uuid:string,type:string,name:string,project:string|null,environment:string|null,url:string|null,search:string}
+ */
+ protected function resourceRow(BaseModel $resource): array
+ {
+ $type = match (true) {
+ $resource instanceof Application => 'application',
+ $resource instanceof Service => 'service',
+ default => 'database',
+ };
+ $environment = $resource->environment;
+ $project = $environment?->project;
+ $routeName = "project.{$type}.configuration";
+ $url = ($project && $environment)
+ ? route($routeName, [
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ "{$type}_uuid" => $resource->uuid,
+ ])
+ : null;
+
+ return [
+ 'uuid' => $resource->uuid,
+ 'type' => $type,
+ 'name' => $resource->name,
+ 'project' => $project?->name,
+ 'environment' => $environment?->name,
+ 'url' => $url,
+ 'search' => strtolower(implode(' ', array_filter([
+ $type,
+ $resource->name,
+ $project?->name,
+ $environment?->name,
+ ]))),
+ ];
+ }
+
+ public function render(): View
+ {
+ return view('livewire.destination.resources');
+ }
+}
diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php
index f2cdad074..9d55d7462 100644
--- a/app/Livewire/Destination/Show.php
+++ b/app/Livewire/Destination/Show.php
@@ -2,9 +2,7 @@
namespace App\Livewire\Destination;
-use App\Models\Server;
use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -29,16 +27,8 @@ class Show extends Component
public function mount(string $destination_uuid)
{
try {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
- SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
-
- $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
- if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
- $this->destination = $destination;
- $this->syncData();
- }
- });
- if ($ownedByTeam === false) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
return redirect()->route('destination.index');
}
$this->destination = $destination;
@@ -80,7 +70,7 @@ class Show extends Component
try {
$this->authorize('delete', $this->destination);
- if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
+ if ($this->destination->getMorphClass() === StandaloneDocker::class) {
if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
}
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 @@ class ForcePasswordReset extends Component
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/Help.php b/app/Livewire/Help.php
index 490515875..421e50bcc 100644
--- a/app/Livewire/Help.php
+++ b/app/Livewire/Help.php
@@ -15,7 +15,7 @@ class Help extends Component
#[Validate(['required', 'min:10', 'max:1000'])]
public string $description;
- #[Validate(['required', 'min:3'])]
+ #[Validate(['required', 'min:3', 'max:600'])]
public string $subject;
public function submit()
diff --git a/app/Livewire/Notifications/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/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php
new file mode 100644
index 000000000..6a1b72f80
--- /dev/null
+++ b/app/Livewire/Profile/Appearance.php
@@ -0,0 +1,13 @@
+disableBuildCache = $this->application->settings->disable_build_cache;
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
+ $this->maxRestartCount = $this->application->max_restart_count ?? 10;
}
+
+ // Load stop_grace_period separately since it has its own save handler
+ // Convert null to empty string to prevent dirty detection issues
+ $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
}
private function resetDefaultLabels()
@@ -210,6 +223,7 @@ class Advanced extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Settings saved.');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -228,6 +242,7 @@ class Advanced extends Component
if (is_null($this->customInternalName)) {
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
return;
}
@@ -247,6 +262,47 @@ class Advanced extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function saveStopGracePeriod()
+ {
+ try {
+ $this->authorize('update', $this->application);
+
+ $validated = Validator::make(
+ ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
+ ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
+ [],
+ ['stopGracePeriod' => 'stop grace period']
+ )->validate();
+
+ $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
+ ? null
+ : (int) $validated['stopGracePeriod'];
+ $this->application->settings->save();
+
+ $this->dispatch('success', 'Stop grace period updated.');
+ } catch (ValidationException $e) {
+ throw $e;
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function saveMaxRestartCount()
+ {
+ try {
+ $this->authorize('update', $this->application);
+ $this->validate([
+ 'maxRestartCount' => 'integer|min:0',
+ ]);
+ $this->application->max_restart_count = $this->maxRestartCount;
+ $this->application->save();
+ $this->dispatch('success', 'Max restart count saved.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php
index cc1bf15b9..fb069f65b 100644
--- a/app/Livewire/Project/Application/Configuration.php
+++ b/app/Livewire/Project/Application/Configuration.php
@@ -17,17 +17,10 @@ class Configuration extends Component
public $servers;
- public function getListeners()
- {
- $teamId = auth()->user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
- "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
- 'buildPackUpdated' => '$refresh',
- 'refresh' => '$refresh',
- ];
- }
+ protected $listeners = [
+ 'buildPackUpdated' => '$refresh',
+ 'refresh' => '$refresh',
+ ];
public function mount()
{
@@ -35,7 +28,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -51,8 +44,6 @@ class Configuration extends Component
$this->environment = $environment;
$this->application = $application;
-
-
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
}
diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php
index 954670582..c9f818e2c 100644
--- a/app/Livewire/Project/Application/Deployment/Show.php
+++ b/app/Livewire/Project/Application/Deployment/Show.php
@@ -108,19 +108,6 @@ class Show extends Component
return decode_remote_command_output($this->application_deployment_queue);
}
- public function copyLogs(): string
- {
- $logs = decode_remote_command_output($this->application_deployment_queue)
- ->map(function ($line) {
- return $line['timestamp'].' '.
- (isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
- trim($line['line']);
- })
- ->join("\n");
-
- return sanitizeLogsForExport($logs);
- }
-
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 25ce82eb0..89b1b4217 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Application;
use App\Actions\Application\GenerateConfig;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
+use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -144,7 +145,7 @@ class General extends Component
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'gitRepository' => 'required',
- 'gitBranch' => 'required',
+ 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
@@ -153,12 +154,12 @@ class General extends Component
'staticImage' => 'required',
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
'publishDirectory' => ValidationPatterns::directoryPathRules(),
- 'portsExposes' => ['required', 'string', 'regex:/^(\d+)(,\d+)*$/'],
+ 'portsExposes' => ['nullable', 'string', 'regex:/^(\d+)(,\d+)*$/'],
'portsMappings' => ValidationPatterns::portMappingRules(),
'customNetworkAliases' => 'nullable',
'dockerfile' => 'nullable',
- 'dockerRegistryImageName' => 'nullable',
- 'dockerRegistryImageTag' => 'nullable',
+ 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(),
+ 'dockerRegistryImageTag' => ValidationPatterns::dockerImageTagRules(),
'dockerfileLocation' => ValidationPatterns::filePathRules(),
'dockerComposeLocation' => ValidationPatterns::filePathRules(),
'dockerCompose' => 'nullable',
@@ -197,12 +198,12 @@ class General extends Component
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
- 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- '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.',
@@ -211,7 +212,6 @@ class General extends Component
'buildPack.required' => 'The Build Pack field is required.',
'staticImage.required' => 'The Static Image field is required.',
'baseDirectory.required' => 'The Base Directory field is required.',
- 'portsExposes.required' => 'The Exposed Ports field is required.',
'portsExposes.regex' => 'Ports exposes must be a comma-separated list of port numbers (e.g. 3000,3001).',
...ValidationPatterns::portMappingMessages(),
'isStatic.required' => 'The Static setting is required.',
@@ -606,7 +606,7 @@ class General extends Component
// Sync property to model before checking/modifying
$this->syncData(toModel: true);
- if ($this->buildPack !== 'nixpacks') {
+ if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
@@ -759,7 +759,7 @@ class General extends Component
$this->resetErrorBag();
- $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString();
+ $this->portsExposes = str($this->portsExposes)->replace(' ', '')->trim()->toString() ?: null;
if ($this->portsMappings) {
$this->portsMappings = str($this->portsMappings)->replace(' ', '')->trim()->toString();
}
@@ -848,7 +848,7 @@ class General extends Component
}
if ($this->buildPack === 'dockerimage') {
$this->validate([
- 'dockerRegistryImageName' => 'required',
+ 'dockerRegistryImageName' => ValidationPatterns::dockerImageNameRules(required: true),
]);
}
diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php
index c887e9b83..59b52f557 100644
--- a/app/Livewire/Project/Application/Previews.php
+++ b/app/Livewire/Project/Application/Previews.php
@@ -338,10 +338,11 @@ class Previews extends Component
private function stopContainers(array $containers, $server)
{
$containersToStop = collect($containers)->pluck('Names')->toArray();
+ $timeout = $this->application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Livewire/Project/Application/ServerStatusBadge.php b/app/Livewire/Project/Application/ServerStatusBadge.php
new file mode 100644
index 000000000..459271e28
--- /dev/null
+++ b/app/Livewire/Project/Application/ServerStatusBadge.php
@@ -0,0 +1,41 @@
+currentTeam();
+ if (! $team) {
+ return [];
+ }
+
+ return [
+ "echo-private:team.{$team->id},ServiceStatusChanged" => 'refreshStatus',
+ "echo-private:team.{$team->id},ServiceChecked" => 'refreshStatus',
+ ];
+ }
+
+ public function refreshStatus(): void
+ {
+ $this->application->refresh();
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.application.server-status-badge');
+ }
+}
diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php
index 422dd6b28..3ee5919fe 100644
--- a/app/Livewire/Project/Application/Source.php
+++ b/app/Livewire/Project/Application/Source.php
@@ -3,7 +3,10 @@
namespace App\Livewire\Project\Application;
use App\Models\Application;
+use App\Models\GithubApp;
+use App\Models\GitlabApp;
use App\Models\PrivateKey;
+use App\Rules\ValidGitBranch;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -21,13 +24,13 @@ class Source extends Component
#[Validate(['nullable', 'string'])]
public ?string $privateKeyName = null;
- #[Validate(['nullable', 'integer'])]
+ #[Locked]
public ?int $privateKeyId = null;
#[Validate(['required', 'string'])]
public string $gitRepository;
- #[Validate(['required', 'string'])]
+ #[Validate(['required', 'string', new ValidGitBranch])]
public string $gitBranch;
#[Validate(['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'])]
@@ -103,12 +106,14 @@ class Source extends Component
{
try {
$this->authorize('update', $this->application);
- $this->privateKeyId = $privateKeyId;
+ $key = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId);
+ $this->privateKeyId = $key->id;
$this->syncData(true);
$this->getPrivateKeys();
$this->application->refresh();
$this->privateKeyName = $this->application->private_key->name;
$this->dispatch('success', 'Private key updated!');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -124,6 +129,7 @@ class Source extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Application source updated!');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -134,8 +140,11 @@ class Source extends Component
try {
$this->authorize('update', $this->application);
+ $allowedSourceTypes = [GithubApp::class, GitlabApp::class];
+ abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404);
+ $source = $sourceType::ownedByCurrentTeam()->findOrFail($sourceId);
$this->application->update([
- 'source_id' => $sourceId,
+ 'source_id' => $source->id,
'source_type' => $sourceType,
]);
diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php
index a18022882..ef106a65f 100644
--- a/app/Livewire/Project/Database/BackupEdit.php
+++ b/app/Livewire/Project/Database/BackupEdit.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Database;
use App\Models\ScheduledDatabaseBackup;
+use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
@@ -144,7 +145,7 @@ class BackupEdit extends Component
try {
$server = null;
- if ($this->backup->database instanceof \App\Models\ServiceDatabase) {
+ if ($this->backup->database instanceof ServiceDatabase) {
$server = $this->backup->database->service->destination->server;
} elseif ($this->backup->database->destination && $this->backup->database->destination->server) {
$server = $this->backup->database->destination->server;
@@ -170,7 +171,7 @@ class BackupEdit extends Component
$this->backup->delete();
- if ($this->backup->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
$serviceDatabase = $this->backup->database;
return redirect()->route('project.service.database.backups', [
@@ -182,7 +183,7 @@ class BackupEdit extends Component
} else {
return redirect()->route('project.database.backup.index', $this->parameters);
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
$this->dispatch('error', 'Failed to delete backup: '.$e->getMessage());
return handleError($e, $this);
@@ -207,6 +208,13 @@ class BackupEdit extends Component
$this->backup->s3_storage_id = null;
}
+ // S3 backup cannot be enabled without a valid S3 storage owned by the team
+ $availableS3Ids = collect($this->s3s)->pluck('id');
+ if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
+ $this->backup->save_s3 = $this->saveS3 = false;
+ $this->backup->s3_storage_id = $this->s3StorageId = null;
+ }
+
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
@@ -214,7 +222,7 @@ class BackupEdit extends Component
$isValid = validate_cron_expression($this->backup->frequency);
if (! $isValid) {
- throw new \Exception('Invalid Cron / Human expression');
+ throw new Exception('Invalid Cron / Human expression');
}
$this->validate();
}
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index e06629d10..694674326 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -40,18 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public function getListeners()
+ public function getListeners(): array
{
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -76,16 +79,18 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'clickhouseAdminUser' => 'required|string',
- 'clickhouseAdminPassword' => 'required|string',
+ 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
+ ),
+ 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
- 'dbUrl' => 'nullable|string',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
];
}
@@ -96,10 +101,8 @@ class General extends Component
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.',
@@ -127,9 +130,6 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -142,8 +142,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -192,6 +190,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -200,9 +199,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -218,6 +221,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Database/Clickhouse/StatusInfo.php b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php
new file mode 100644
index 000000000..51a3192fa
--- /dev/null
+++ b/app/Livewire/Project/Database/Clickhouse/StatusInfo.php
@@ -0,0 +1,31 @@
+currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
- ];
- }
-
public function mount()
{
try {
@@ -34,7 +26,7 @@ class Configuration extends Component
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -55,10 +47,10 @@ class Configuration extends Component
$this->dispatch('configurationChanged');
}
} catch (\Throwable $e) {
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
return redirect()->route('dashboard');
}
- if ($e instanceof \Illuminate\Support\ItemNotFoundException) {
+ if ($e instanceof ItemNotFoundException) {
return redirect()->route('dashboard');
}
diff --git a/app/Livewire/Project/Database/CreateScheduledBackup.php b/app/Livewire/Project/Database/CreateScheduledBackup.php
index 7f807afe2..7384adcff 100644
--- a/app/Livewire/Project/Database/CreateScheduledBackup.php
+++ b/app/Livewire/Project/Database/CreateScheduledBackup.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Database;
+use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
+use App\Models\ServiceDatabase;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
@@ -48,6 +50,20 @@ class CreateScheduledBackup extends Component
$this->validate();
+ if ($this->saveToS3) {
+ $s3StorageExists = ! is_null($this->s3StorageId)
+ && S3Storage::where('team_id', currentTeam()->id)
+ ->where('is_usable', true)
+ ->whereKey($this->s3StorageId)
+ ->exists();
+
+ if (! $s3StorageExists) {
+ $this->dispatch('error', 'Please select a valid S3 storage to enable S3 backups.');
+
+ return;
+ }
+ }
+
$isValid = validate_cron_expression($this->frequency);
if (! $isValid) {
$this->dispatch('error', 'Invalid Cron / Human expression.');
@@ -74,7 +90,7 @@ class CreateScheduledBackup extends Component
}
$databaseBackup = ScheduledDatabaseBackup::create($payload);
- if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($this->database->getMorphClass() === ServiceDatabase::class) {
$this->dispatch('refreshScheduledBackups', $databaseBackup->id);
} else {
$this->dispatch('refreshScheduledBackups');
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index 5176f5ff9..f196b9dfb 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Dragonfly;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -40,25 +38,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public ?Carbon $certificateValidUntil = null;
-
- public bool $enable_ssl = false;
-
- public function getListeners()
+ public function getListeners(): array
{
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -73,12 +67,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -89,17 +77,16 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'dragonflyPassword' => 'required|string',
+ 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
- 'dbUrl' => 'nullable|string',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
- 'enable_ssl' => 'nullable|boolean',
];
}
@@ -109,8 +96,7 @@ class General extends Component
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.',
@@ -136,11 +122,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
- $this->database->enable_ssl = $this->enable_ssl;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -152,9 +134,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->enable_ssl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -203,6 +182,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -211,9 +191,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -229,6 +213,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -240,67 +225,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $server = $this->database->destination->server;
-
- $caCert = $server->sslCertificates()
- ->where('is_ca_certificate', true)
- ->first();
-
- if (! $caCert) {
- $server->generateCaCertificate();
- $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Dragonfly/StatusInfo.php b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php
new file mode 100644
index 000000000..baeb3d09f
--- /dev/null
+++ b/app/Livewire/Project/Database/Dragonfly/StatusInfo.php
@@ -0,0 +1,26 @@
+authorize('view', $this->database);
+ $this->syncData();
+ }
+
+ public function syncData(bool $toModel = false): void
+ {
+ if ($toModel) {
+ $this->validate();
+ $this->database->health_check_enabled = $this->healthCheckEnabled;
+ $this->database->health_check_interval = $this->healthCheckInterval;
+ $this->database->health_check_timeout = $this->healthCheckTimeout;
+ $this->database->health_check_retries = $this->healthCheckRetries;
+ $this->database->health_check_start_period = $this->healthCheckStartPeriod;
+ $this->database->save();
+ } else {
+ $this->healthCheckEnabled = $this->database->health_check_enabled;
+ $this->healthCheckInterval = $this->database->health_check_interval;
+ $this->healthCheckTimeout = $this->database->health_check_timeout;
+ $this->healthCheckRetries = $this->database->health_check_retries;
+ $this->healthCheckStartPeriod = $this->database->health_check_start_period;
+ }
+ }
+
+ public function instantSave(): void
+ {
+ $this->submit();
+ }
+
+ public function submit(): void
+ {
+ $updateSuccessful = false;
+
+ try {
+ $this->authorize('update', $this->database);
+ $this->syncData(true);
+ $updateSuccessful = true;
+ $this->dispatch('success', 'Health check updated. Restart the database to apply the changes.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+
+ if (! $updateSuccessful) {
+ return;
+ }
+
+ $this->markConfigurationChanged();
+ }
+
+ public function toggleHealthcheck(): void
+ {
+ $updateSuccessful = false;
+
+ try {
+ $this->authorize('update', $this->database);
+ $this->healthCheckEnabled = ! $this->healthCheckEnabled;
+ $this->syncData(true);
+ $updateSuccessful = true;
+ $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'. Restart the database to apply the changes.');
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+
+ if (! $updateSuccessful) {
+ return;
+ }
+
+ $this->markConfigurationChanged();
+ }
+
+ private function markConfigurationChanged(): void
+ {
+ if (is_null($this->database->config_hash)) {
+ $this->database->isConfigurationChanged(true);
+
+ return;
+ }
+
+ $this->dispatch('configurationChanged');
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.database.health');
+ }
+}
diff --git a/app/Livewire/Project/Database/Import.php b/app/Livewire/Project/Database/Import.php
index 1cdc681cd..ea04658cf 100644
--- a/app/Livewire/Project/Database/Import.php
+++ b/app/Livewire/Project/Database/Import.php
@@ -2,14 +2,14 @@
namespace App\Livewire\Project\Database;
-use App\Models\S3Storage;
-use App\Models\Server;
-use App\Models\Service;
-use App\Support\ValidationPatterns;
+use App\Models\ServiceDatabase;
+use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDragonfly;
+use App\Models\StandaloneKeydb;
+use App\Models\StandaloneRedis;
+use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Storage;
-use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -17,797 +17,134 @@ class Import extends Component
{
use AuthorizesRequests;
- /**
- * Validate that a string is safe for use as an S3 bucket name.
- * Allows alphanumerics, dots, dashes, and underscores.
- */
- private function validateBucketName(string $bucket): bool
- {
- return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
- }
-
- /**
- * Validate that a string is safe for use as an S3 path.
- * Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
- */
- private function validateS3Path(string $path): bool
- {
- // Must not be empty
- if (empty($path)) {
- return false;
- }
-
- // Must not contain dangerous shell metacharacters or command injection patterns
- $dangerousPatterns = [
- '..', // Directory traversal
- '$(', // Command substitution
- '`', // Backtick command substitution
- '|', // Pipe
- ';', // Command separator
- '&', // Background/AND
- '>', // Redirect
- '<', // Redirect
- "\n", // Newline
- "\r", // Carriage return
- "\0", // Null byte
- "'", // Single quote
- '"', // Double quote
- '\\', // Backslash
- ];
-
- foreach ($dangerousPatterns as $pattern) {
- if (str_contains($path, $pattern)) {
- return false;
- }
- }
-
- // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
- return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
- }
-
- /**
- * Validate that a string is safe for use as a file path on the server.
- */
- private function validateServerPath(string $path): bool
- {
- // Must be an absolute path
- if (! str_starts_with($path, '/')) {
- return false;
- }
-
- // Must not contain dangerous shell metacharacters or command injection patterns
- $dangerousPatterns = [
- '..', // Directory traversal
- '$(', // Command substitution
- '`', // Backtick command substitution
- '|', // Pipe
- ';', // Command separator
- '&', // Background/AND
- '>', // Redirect
- '<', // Redirect
- "\n", // Newline
- "\r", // Carriage return
- "\0", // Null byte
- "'", // Single quote
- '"', // Double quote
- '\\', // Backslash
- ];
-
- foreach ($dangerousPatterns as $pattern) {
- if (str_contains($path, $pattern)) {
- return false;
- }
- }
-
- // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
- return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
- }
-
- public bool $unsupported = false;
-
- // Store IDs instead of models for proper Livewire serialization
#[Locked]
public ?int $resourceId = null;
#[Locked]
public ?string $resourceType = null;
- #[Locked]
- public ?int $serverId = null;
-
- // View-friendly properties to avoid computed property access in Blade
- #[Locked]
- public string $resourceUuid = '';
-
public string $resourceStatus = '';
- #[Locked]
- public string $resourceDbType = '';
+ public string $resourceUuid = '';
- public array $parameters = [];
+ public bool $unsupported = false;
- public array $containers = [];
-
- public bool $scpInProgress = false;
-
- public bool $importRunning = false;
-
- public ?string $filename = null;
-
- public ?string $filesize = null;
-
- public bool $isUploading = false;
-
- public int $progress = 0;
-
- public bool $error = false;
-
- #[Locked]
- public string $container;
-
- public array $importCommands = [];
-
- public bool $dumpAll = false;
-
- public string $restoreCommandText = '';
-
- public string $customLocation = '';
-
- public ?int $activityId = null;
-
- public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
-
- public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
-
- public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
-
- public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
-
- // S3 Restore properties
- public array $availableS3Storages = [];
-
- public ?int $s3StorageId = null;
-
- public string $s3Path = '';
-
- public ?int $s3FileSize = null;
-
- #[Computed]
- public function resource()
+ public function getListeners(): array
{
- if ($this->resourceId === null || $this->resourceType === null) {
- return null;
+ $listeners = ['databaseUpdated' => 'refreshStatus'];
+
+ $user = Auth::user();
+ if (! $user) {
+ return $listeners;
}
- return $this->resourceType::find($this->resourceId);
- }
+ $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refreshStatus';
- #[Computed]
- public function server()
- {
- if ($this->serverId === null) {
- return null;
+ $team = $user->currentTeam();
+ if ($team) {
+ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refreshStatus';
}
- return Server::ownedByCurrentTeam()->find($this->serverId);
+ return $listeners;
}
- public function getListeners()
+ public function mount(): void
{
- $userId = Auth::id();
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
- 'slideOverClosed' => 'resetActivityId',
- ];
- }
-
- public function resetActivityId()
- {
- $this->activityId = null;
- }
-
- public function mount()
- {
- $this->parameters = get_route_parameters();
- $this->getContainers();
- $this->loadAvailableS3Storages();
- }
-
- public function updatedDumpAll($value)
- {
- $morphClass = $this->resource->getMorphClass();
-
- // Handle ServiceDatabase by checking the database type
- if ($morphClass === \App\Models\ServiceDatabase::class) {
- $dbType = $this->resource->databaseType();
- if (str_contains($dbType, 'mysql')) {
- $morphClass = 'mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $morphClass = 'mariadb';
- } elseif (str_contains($dbType, 'postgres')) {
- $morphClass = 'postgresql';
- }
- }
-
- switch ($morphClass) {
- case \App\Models\StandaloneMariadb::class:
- case 'mariadb':
- if ($value === true) {
- $this->mariadbRestoreCommand = <<<'EOD'
-for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
- mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
-done && \
-mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
-mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
-(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
-EOD;
- $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
- } else {
- $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
- }
- break;
- case \App\Models\StandaloneMysql::class:
- case 'mysql':
- if ($value === true) {
- $this->mysqlRestoreCommand = <<<'EOD'
-for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
- mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
-done && \
-mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
-mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
-(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
-EOD;
- $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
- } else {
- $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
- }
- break;
- case \App\Models\StandalonePostgresql::class:
- case 'postgresql':
- if ($value === true) {
- $this->postgresqlRestoreCommand = <<<'EOD'
-psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
-psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
-createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
-EOD;
- $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
- } else {
- $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
- }
- break;
- }
-
- }
-
- public function getContainers()
- {
- $this->containers = [];
- $teamId = data_get(auth()->user()->currentTeam(), 'id');
-
- // Try to find resource by route parameter
- $databaseUuid = data_get($this->parameters, 'database_uuid');
- $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
-
- $resource = null;
- if ($databaseUuid) {
- // Standalone database route
- $resource = getResourceByUuid($databaseUuid, $teamId);
- if (is_null($resource)) {
- abort(404);
- }
- } elseif ($stackServiceUuid) {
- // ServiceDatabase route - look up the service database
- $serviceUuid = data_get($this->parameters, 'service_uuid');
- $service = Service::whereUuid($serviceUuid)->first();
- if (! $service) {
- abort(404);
- }
- $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
- if (is_null($resource)) {
- abort(404);
- }
- } else {
- abort(404);
- }
-
+ $resource = $this->resolveResourceFromRoute();
$this->authorize('view', $resource);
- // Store IDs for Livewire serialization
$this->resourceId = $resource->id;
$this->resourceType = get_class($resource);
- // Store view-friendly properties
+ $this->refreshStatus();
+ }
+
+ public function refreshStatus(): void
+ {
+ $resource = $this->resolveStoredResource();
+ $this->authorize('view', $resource);
+
+ $resource->refresh();
+ $this->resourceUuid = $resource->uuid;
$this->resourceStatus = $resource->status ?? '';
+ $this->unsupported = $this->isUnsupportedResource($resource);
+ }
- // Handle ServiceDatabase server access differently
- if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
- $server = $resource->service?->server;
- if (! $server) {
- abort(404, 'Server not found for this service database.');
- }
- $this->serverId = $server->id;
- $this->container = $resource->name.'-'.$resource->service->uuid;
- $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
+ public function render(): View
+ {
+ return view('livewire.project.database.import');
+ }
- // Determine database type for ServiceDatabase
- $dbType = $resource->databaseType();
- if (str_contains($dbType, 'postgres')) {
- $this->resourceDbType = 'standalone-postgresql';
- } elseif (str_contains($dbType, 'mysql')) {
- $this->resourceDbType = 'standalone-mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $this->resourceDbType = 'standalone-mariadb';
- } elseif (str_contains($dbType, 'mongo')) {
- $this->resourceDbType = 'standalone-mongodb';
- } else {
- $this->resourceDbType = $dbType;
+ private function resolveResourceFromRoute(): object
+ {
+ $parameters = get_route_parameters();
+ $teamId = data_get(Auth::user()?->currentTeam(), 'id');
+ $databaseUuid = data_get($parameters, 'database_uuid');
+ $stackServiceUuid = data_get($parameters, 'stack_service_uuid');
+
+ if ($databaseUuid) {
+ $resource = getResourceByUuid($databaseUuid, $teamId);
+ if ($resource) {
+ return $resource;
}
- } else {
- $server = $resource->destination?->server;
- if (! $server) {
- abort(404, 'Server not found for this database.');
- }
- $this->serverId = $server->id;
- $this->container = $resource->uuid;
- $this->resourceUuid = $resource->uuid;
- $this->resourceDbType = $resource->type();
+
+ abort(404);
}
- if (str($resource->status)->startsWith('running')) {
- $this->containers[] = $this->container;
+ if ($stackServiceUuid) {
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', data_get($parameters, 'project_uuid'))
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', data_get($parameters, 'environment_uuid'))
+ ->firstOrFail();
+ $service = $environment->services()->whereUuid(data_get($parameters, 'service_uuid'))->firstOrFail();
+ $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
+ if ($resource) {
+ return $resource;
+ }
}
+ abort(404);
+ }
+
+ private function resolveStoredResource(): object
+ {
+ if ($this->resourceId === null || $this->resourceType === null) {
+ return $this->resolveResourceFromRoute();
+ }
+
+ $resource = $this->resourceType::find($this->resourceId);
+ if ($resource) {
+ return $resource;
+ }
+
+ abort(404);
+ }
+
+ private function isUnsupportedResource(object $resource): bool
+ {
if (
- $resource->getMorphClass() === \App\Models\StandaloneRedis::class ||
- $resource->getMorphClass() === \App\Models\StandaloneKeydb::class ||
- $resource->getMorphClass() === \App\Models\StandaloneDragonfly::class ||
- $resource->getMorphClass() === \App\Models\StandaloneClickhouse::class
+ $resource instanceof StandaloneRedis ||
+ $resource instanceof StandaloneKeydb ||
+ $resource instanceof StandaloneDragonfly ||
+ $resource instanceof StandaloneClickhouse
) {
- $this->unsupported = true;
+ return true;
}
- // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
- if ($resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
+ if ($resource instanceof ServiceDatabase) {
$dbType = $resource->databaseType();
- if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
- str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
- $this->unsupported = true;
- }
- }
- }
- public function checkFile()
- {
- if (filled($this->customLocation)) {
- // Validate the custom location to prevent command injection
- if (! $this->validateServerPath($this->customLocation)) {
- $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return;
- }
-
- try {
- $escapedPath = escapeshellarg($this->customLocation);
- $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
- if (blank($result)) {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
-
- return;
- }
- $this->filename = $this->customLocation;
- $this->dispatch('success', 'The file exists.');
- } catch (\Throwable $e) {
- return handleError($e, $this);
- }
- }
- }
-
- public function runImport(string $password = ''): bool|string
- {
- if (! verifyPasswordConfirmation($password, $this)) {
- return 'The provided password is incorrect.';
+ return str_contains($dbType, 'redis') ||
+ str_contains($dbType, 'keydb') ||
+ str_contains($dbType, 'dragonfly') ||
+ str_contains($dbType, 'clickhouse');
}
- $this->authorize('update', $this->resource);
-
- if (! ValidationPatterns::isValidContainerName($this->container)) {
- $this->dispatch('error', 'Invalid container name.');
-
- return true;
- }
-
- if ($this->filename === '') {
- $this->dispatch('error', 'Please select a file to import.');
-
- return true;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return true;
- }
-
- try {
- $this->importRunning = true;
- $this->importCommands = [];
- $backupFileName = "upload/{$this->resourceUuid}/restore";
-
- // Check if an uploaded file exists first (takes priority over custom location)
- if (Storage::exists($backupFileName)) {
- $path = Storage::path($backupFileName);
- $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
- instant_scp($path, $tmpPath, $this->server);
- Storage::delete($backupFileName);
- $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
- } elseif (filled($this->customLocation)) {
- // Validate the custom location to prevent command injection
- if (! $this->validateServerPath($this->customLocation)) {
- $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
-
- return true;
- }
- $tmpPath = '/tmp/restore_'.$this->resourceUuid;
- $escapedCustomLocation = escapeshellarg($this->customLocation);
- $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
- } else {
- $this->dispatch('error', 'The file does not exist or has been deleted.');
-
- return true;
- }
-
- // Copy the restore command to a script file
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- $restoreCommand = $this->buildRestoreCommand($tmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
- $this->importCommands[] = "chmod +x {$scriptPath}";
- $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
-
- $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
- $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- if (! empty($this->importCommands)) {
- $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
- 'scriptPath' => $scriptPath,
- 'tmpPath' => $tmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
- $this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
- $this->dispatch('activityMonitor', $activity->id);
- $this->dispatch('databaserestore');
- }
- } catch (\Throwable $e) {
- handleError($e, $this);
-
- return true;
- } finally {
- $this->filename = null;
- $this->importCommands = [];
- }
-
- return true;
- }
-
- public function loadAvailableS3Storages()
- {
- try {
- $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
- ->where('is_usable', true)
- ->get()
- ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
- ->toArray();
- } catch (\Throwable $e) {
- $this->availableS3Storages = [];
- }
- }
-
- public function updatedS3Path($value)
- {
- // Reset validation state when path changes
- $this->s3FileSize = null;
-
- // Ensure path starts with a slash
- if ($value !== null && $value !== '') {
- $this->s3Path = str($value)->trim()->start('/')->value();
- }
- }
-
- public function updatedS3StorageId()
- {
- // Reset validation state when storage changes
- $this->s3FileSize = null;
- }
-
- public function checkS3File()
- {
- if (! $this->s3StorageId) {
- $this->dispatch('error', 'Please select an S3 storage.');
-
- return;
- }
-
- if (blank($this->s3Path)) {
- $this->dispatch('error', 'Please provide an S3 path.');
-
- return;
- }
-
- // Clean the path (remove leading slash if present)
- $cleanPath = ltrim($this->s3Path, '/');
-
- // Validate the S3 path early to prevent command injection in subsequent operations
- if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return;
- }
-
- try {
- $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
-
- // Validate bucket name early
- if (! $this->validateBucketName($s3Storage->bucket)) {
- $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
-
- return;
- }
-
- // Test connection
- $s3Storage->testConnection();
-
- // Build S3 disk configuration
- $disk = Storage::build([
- 'driver' => 's3',
- 'region' => $s3Storage->region,
- 'key' => $s3Storage->key,
- 'secret' => $s3Storage->secret,
- 'bucket' => $s3Storage->bucket,
- 'endpoint' => $s3Storage->endpoint,
- 'use_path_style_endpoint' => true,
- ]);
-
- // Check if file exists
- if (! $disk->exists($cleanPath)) {
- $this->dispatch('error', 'File not found in S3. Please check the path.');
-
- return;
- }
-
- // Get file size
- $this->s3FileSize = $disk->size($cleanPath);
-
- $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
- } catch (\Throwable $e) {
- $this->s3FileSize = null;
-
- return handleError($e, $this);
- }
- }
-
- public function restoreFromS3(string $password = ''): bool|string
- {
- if (! verifyPasswordConfirmation($password, $this)) {
- return 'The provided password is incorrect.';
- }
-
- $this->authorize('update', $this->resource);
-
- if (! ValidationPatterns::isValidContainerName($this->container)) {
- $this->dispatch('error', 'Invalid container name.');
-
- return true;
- }
-
- if (! $this->s3StorageId || blank($this->s3Path)) {
- $this->dispatch('error', 'Please select S3 storage and provide a path first.');
-
- return true;
- }
-
- if (is_null($this->s3FileSize)) {
- $this->dispatch('error', 'Please check the file first by clicking "Check File".');
-
- return true;
- }
-
- if (! $this->server) {
- $this->dispatch('error', 'Server not found. Please refresh the page.');
-
- return true;
- }
-
- try {
- $this->importRunning = true;
-
- $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
-
- $key = $s3Storage->key;
- $secret = $s3Storage->secret;
- $bucket = $s3Storage->bucket;
- $endpoint = $s3Storage->endpoint;
-
- // Validate bucket name to prevent command injection
- if (! $this->validateBucketName($bucket)) {
- $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
-
- return true;
- }
-
- // Clean the S3 path
- $cleanPath = ltrim($this->s3Path, '/');
-
- // Validate the S3 path to prevent command injection
- if (! $this->validateS3Path($cleanPath)) {
- $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
-
- return true;
- }
-
- // Get helper image
- $helperImage = config('constants.coolify.helper_image');
- $latestVersion = getHelperVersion();
- $fullImageName = "{$helperImage}:{$latestVersion}";
-
- // Get the database destination network
- if ($this->resource->getMorphClass() === \App\Models\ServiceDatabase::class) {
- $destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
- } else {
- $destinationNetwork = $this->resource->destination->network ?? 'coolify';
- }
-
- // Generate unique names for this operation
- $containerName = "s3-restore-{$this->resourceUuid}";
- $helperTmpPath = '/tmp/'.basename($cleanPath);
- $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
- $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
- $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
-
- // Prepare all commands in sequence
- $commands = [];
-
- // 1. Clean up any existing helper container and temp files from previous runs
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
- $commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
-
- // 2. Start helper container on the database network
- $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
-
- // 3. Configure S3 access in helper container
- $escapedEndpoint = escapeshellarg($endpoint);
- $escapedKey = escapeshellarg($key);
- $escapedSecret = escapeshellarg($secret);
- $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
-
- // 4. Check file exists in S3 (bucket and path already validated above)
- $escapedBucket = escapeshellarg($bucket);
- $escapedCleanPath = escapeshellarg($cleanPath);
- $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
- $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
-
- // 5. Download from S3 to helper container (progress shown by default)
- $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
- $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
-
- // 6. Copy from helper to server, then immediately to database container
- $commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
- $commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
-
- // 7. Cleanup helper container and server temp file immediately (no longer needed)
- $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
- $commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
-
- // 8. Build and execute restore command inside database container
- $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
-
- $restoreCommandBase64 = base64_encode($restoreCommand);
- $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
- $commands[] = "chmod +x {$scriptPath}";
- $commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
-
- // 9. Execute restore and cleanup temp files immediately after completion
- $commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
- $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
-
- // Execute all commands with cleanup event (as safety net for edge cases)
- $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
- 'containerName' => $containerName,
- 'serverTmpPath' => $serverTmpPath,
- 'scriptPath' => $scriptPath,
- 'containerTmpPath' => $containerTmpPath,
- 'container' => $this->container,
- 'serverId' => $this->server->id,
- ]);
-
- // Track the activity ID
- $this->activityId = $activity->id;
-
- // Dispatch activity to the monitor and open slide-over
- $this->dispatch('activityMonitor', $activity->id);
- $this->dispatch('databaserestore');
- $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
- } catch (\Throwable $e) {
- $this->importRunning = false;
- handleError($e, $this);
-
- return true;
- }
-
- return true;
- }
-
- public function buildRestoreCommand(string $tmpPath): string
- {
- $morphClass = $this->resource->getMorphClass();
-
- // Handle ServiceDatabase by checking the database type
- if ($morphClass === \App\Models\ServiceDatabase::class) {
- $dbType = $this->resource->databaseType();
- if (str_contains($dbType, 'mysql')) {
- $morphClass = 'mysql';
- } elseif (str_contains($dbType, 'mariadb')) {
- $morphClass = 'mariadb';
- } elseif (str_contains($dbType, 'postgres')) {
- $morphClass = 'postgresql';
- } elseif (str_contains($dbType, 'mongo')) {
- $morphClass = 'mongodb';
- }
- }
-
- switch ($morphClass) {
- case \App\Models\StandaloneMariadb::class:
- case 'mariadb':
- $restoreCommand = $this->mariadbRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMysql::class:
- case 'mysql':
- $restoreCommand = $this->mysqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
- } else {
- $restoreCommand .= " < {$tmpPath}";
- }
- break;
- case \App\Models\StandalonePostgresql::class:
- case 'postgresql':
- $restoreCommand = $this->postgresqlRestoreCommand;
- if ($this->dumpAll) {
- $restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
- } else {
- $restoreCommand .= " {$tmpPath}";
- }
- break;
- case \App\Models\StandaloneMongodb::class:
- case 'mongodb':
- $restoreCommand = $this->mongodbRestoreCommand;
- if ($this->dumpAll === false) {
- $restoreCommand .= "{$tmpPath}";
- }
- break;
- default:
- $restoreCommand = '';
- }
-
- return $restoreCommand;
+ return false;
}
}
diff --git a/app/Livewire/Project/Database/ImportForm.php b/app/Livewire/Project/Database/ImportForm.php
new file mode 100644
index 000000000..ccc7b347d
--- /dev/null
+++ b/app/Livewire/Project/Database/ImportForm.php
@@ -0,0 +1,825 @@
+', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
+ }
+
+ /**
+ * Validate that a string is safe for use as a file path on the server.
+ */
+ private function validateServerPath(string $path): bool
+ {
+ // Must be an absolute path
+ if (! str_starts_with($path, '/')) {
+ return false;
+ }
+
+ // Must not contain dangerous shell metacharacters or command injection patterns
+ $dangerousPatterns = [
+ '..', // Directory traversal
+ '$(', // Command substitution
+ '`', // Backtick command substitution
+ '|', // Pipe
+ ';', // Command separator
+ '&', // Background/AND
+ '>', // Redirect
+ '<', // Redirect
+ "\n", // Newline
+ "\r", // Carriage return
+ "\0", // Null byte
+ "'", // Single quote
+ '"', // Double quote
+ '\\', // Backslash
+ ];
+
+ foreach ($dangerousPatterns as $pattern) {
+ if (str_contains($path, $pattern)) {
+ return false;
+ }
+ }
+
+ // Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
+ return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
+ }
+
+ public bool $unsupported = false;
+
+ // Store IDs instead of models for proper Livewire serialization
+ #[Locked]
+ public ?int $resourceId = null;
+
+ #[Locked]
+ public ?string $resourceType = null;
+
+ #[Locked]
+ public ?int $serverId = null;
+
+ // View-friendly properties to avoid computed property access in Blade
+ #[Locked]
+ public string $resourceUuid = '';
+
+ public string $resourceStatus = '';
+
+ #[Locked]
+ public string $resourceDbType = '';
+
+ public array $parameters = [];
+
+ public array $containers = [];
+
+ public bool $scpInProgress = false;
+
+ public bool $importRunning = false;
+
+ public ?string $filename = null;
+
+ public ?string $filesize = null;
+
+ public bool $isUploading = false;
+
+ public int $progress = 0;
+
+ public bool $error = false;
+
+ #[Locked]
+ public string $container;
+
+ public array $importCommands = [];
+
+ public bool $dumpAll = false;
+
+ public string $restoreCommandText = '';
+
+ public string $customLocation = '';
+
+ public ?int $activityId = null;
+
+ public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+
+ public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
+
+ public string $mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
+
+ public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
+
+ // S3 Restore properties
+ public array $availableS3Storages = [];
+
+ public ?int $s3StorageId = null;
+
+ public string $s3Path = '';
+
+ public ?int $s3FileSize = null;
+
+ #[Computed]
+ public function resource()
+ {
+ if ($this->resourceId === null || $this->resourceType === null) {
+ return null;
+ }
+
+ return $this->resourceType::find($this->resourceId);
+ }
+
+ #[Computed]
+ public function server()
+ {
+ if ($this->serverId === null) {
+ return null;
+ }
+
+ return Server::ownedByCurrentTeam()->find($this->serverId);
+ }
+
+ protected $listeners = [
+ 'slideOverClosed' => 'resetActivityId',
+ ];
+
+ public function resetActivityId()
+ {
+ $this->activityId = null;
+ }
+
+ public function mount()
+ {
+ $this->parameters = get_route_parameters();
+ $this->getContainers();
+ $this->loadAvailableS3Storages();
+ }
+
+ public function updatedDumpAll($value)
+ {
+ $morphClass = $this->resource->getMorphClass();
+
+ // Handle ServiceDatabase by checking the database type
+ if ($morphClass === ServiceDatabase::class) {
+ $dbType = $this->resource->databaseType();
+ if (str_contains($dbType, 'mysql')) {
+ $morphClass = 'mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $morphClass = 'mariadb';
+ } elseif (str_contains($dbType, 'postgres')) {
+ $morphClass = 'postgresql';
+ }
+ }
+
+ switch ($morphClass) {
+ case StandaloneMariadb::class:
+ case 'mariadb':
+ if ($value === true) {
+ $this->mariadbRestoreCommand = <<<'EOD'
+for pid in $(mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
+ mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
+done && \
+mariadb -u root -p$MARIADB_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mariadb -u root -p$MARIADB_ROOT_PASSWORD && \
+mariadb -u root -p$MARIADB_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MARIADB_DATABASE:-default}\`;" && \
+(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}
+EOD;
+ $this->restoreCommandText = $this->mariadbRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mariadb -u root -p$MARIADB_ROOT_PASSWORD ${MARIADB_DATABASE:-default}';
+ } else {
+ $this->mariadbRestoreCommand = 'mariadb -u $MARIADB_USER -p$MARIADB_PASSWORD $MARIADB_DATABASE';
+ }
+ break;
+ case StandaloneMysql::class:
+ case 'mysql':
+ if ($value === true) {
+ $this->mysqlRestoreCommand = <<<'EOD'
+for pid in $(mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT id FROM information_schema.processlist WHERE user != 'root';"); do
+ mysql -u root -p$MYSQL_ROOT_PASSWORD -e "KILL $pid" 2>/dev/null || true
+done && \
+mysql -u root -p$MYSQL_ROOT_PASSWORD -N -e "SELECT CONCAT('DROP DATABASE IF EXISTS \`',schema_name,'\`;') FROM information_schema.schemata WHERE schema_name NOT IN ('information_schema','mysql','performance_schema','sys');" | mysql -u root -p$MYSQL_ROOT_PASSWORD && \
+mysql -u root -p$MYSQL_ROOT_PASSWORD -e "CREATE DATABASE IF NOT EXISTS \`${MYSQL_DATABASE:-default}\`;" && \
+(gunzip -cf $tmpPath 2>/dev/null || cat $tmpPath) | sed -e '/^CREATE DATABASE/d' -e '/^USE \`mysql\`/d' | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}
+EOD;
+ $this->restoreCommandText = $this->mysqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | mysql -u root -p$MYSQL_ROOT_PASSWORD ${MYSQL_DATABASE:-default}';
+ } else {
+ $this->mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
+ }
+ break;
+ case StandalonePostgresql::class:
+ case 'postgresql':
+ if ($value === true) {
+ $this->postgresqlRestoreCommand = <<<'EOD'
+psql -U ${POSTGRES_USER} -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IS NOT NULL AND pid <> pg_backend_pid()" && \
+psql -U ${POSTGRES_USER} -t -c "SELECT datname FROM pg_database WHERE NOT datistemplate" | xargs -I {} dropdb -U ${POSTGRES_USER} --if-exists {} && \
+createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
+EOD;
+ $this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf 2>/dev/null || cat ) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ } else {
+ $this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
+ }
+ break;
+ }
+
+ }
+
+ public function getContainers()
+ {
+ $this->containers = [];
+ $teamId = data_get(auth()->user()->currentTeam(), 'id');
+
+ // Try to find resource by route parameter
+ $databaseUuid = data_get($this->parameters, 'database_uuid');
+ $stackServiceUuid = data_get($this->parameters, 'stack_service_uuid');
+
+ $resource = null;
+ if ($databaseUuid) {
+ // Standalone database route
+ $resource = getResourceByUuid($databaseUuid, $teamId);
+ if (is_null($resource)) {
+ abort(404);
+ }
+ } elseif ($stackServiceUuid) {
+ // ServiceDatabase route - look up the service database
+ $serviceUuid = data_get($this->parameters, 'service_uuid');
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', data_get($this->parameters, 'project_uuid'))
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', data_get($this->parameters, 'environment_uuid'))
+ ->firstOrFail();
+ $service = $environment->services()->whereUuid($serviceUuid)->firstOrFail();
+ $resource = $service->databases()->whereUuid($stackServiceUuid)->first();
+ if (is_null($resource)) {
+ abort(404);
+ }
+ } else {
+ abort(404);
+ }
+
+ $this->authorize('view', $resource);
+
+ // Store IDs for Livewire serialization
+ $this->resourceId = $resource->id;
+ $this->resourceType = get_class($resource);
+
+ // Store view-friendly properties
+ $this->resourceStatus = $resource->status ?? '';
+
+ // Handle ServiceDatabase server access differently
+ if ($resource->getMorphClass() === ServiceDatabase::class) {
+ $server = $resource->service?->server;
+ if (! $server) {
+ abort(404, 'Server not found for this service database.');
+ }
+ $this->serverId = $server->id;
+ $this->container = $resource->name.'-'.$resource->service->uuid;
+ $this->resourceUuid = $resource->uuid; // Use ServiceDatabase's own UUID
+
+ // Determine database type for ServiceDatabase
+ $dbType = $resource->databaseType();
+ if (str_contains($dbType, 'postgres')) {
+ $this->resourceDbType = 'standalone-postgresql';
+ } elseif (str_contains($dbType, 'mysql')) {
+ $this->resourceDbType = 'standalone-mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $this->resourceDbType = 'standalone-mariadb';
+ } elseif (str_contains($dbType, 'mongo')) {
+ $this->resourceDbType = 'standalone-mongodb';
+ } else {
+ $this->resourceDbType = $dbType;
+ }
+ } else {
+ $server = $resource->destination?->server;
+ if (! $server) {
+ abort(404, 'Server not found for this database.');
+ }
+ $this->serverId = $server->id;
+ $this->container = $resource->uuid;
+ $this->resourceUuid = $resource->uuid;
+ $this->resourceDbType = $resource->type();
+ }
+
+ if (str($resource->status)->startsWith('running')) {
+ $this->containers[] = $this->container;
+ }
+
+ if (
+ $resource->getMorphClass() === StandaloneRedis::class ||
+ $resource->getMorphClass() === StandaloneKeydb::class ||
+ $resource->getMorphClass() === StandaloneDragonfly::class ||
+ $resource->getMorphClass() === StandaloneClickhouse::class
+ ) {
+ $this->unsupported = true;
+ }
+
+ // Mark unsupported ServiceDatabase types (Redis, KeyDB, etc.)
+ if ($resource->getMorphClass() === ServiceDatabase::class) {
+ $dbType = $resource->databaseType();
+ if (str_contains($dbType, 'redis') || str_contains($dbType, 'keydb') ||
+ str_contains($dbType, 'dragonfly') || str_contains($dbType, 'clickhouse')) {
+ $this->unsupported = true;
+ }
+ }
+ }
+
+ public function checkFile()
+ {
+ if (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return;
+ }
+
+ try {
+ $escapedPath = escapeshellarg($this->customLocation);
+ $result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
+ if (blank($result)) {
+ $this->dispatch('error', 'The file does not exist or has been deleted.');
+
+ return;
+ }
+ $this->filename = $this->customLocation;
+ $this->dispatch('success', 'The file exists.');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+ }
+
+ public function runImport(string $password = ''): bool|string
+ {
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ $this->authorize('update', $this->resource);
+
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
+ if ($this->filename === '') {
+ $this->dispatch('error', 'Please select a file to import.');
+
+ return true;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return true;
+ }
+
+ try {
+ $this->importRunning = true;
+ $this->importCommands = [];
+ $backupFileName = "upload/{$this->resourceUuid}/restore";
+
+ // Check if an uploaded file exists first (takes priority over custom location)
+ if (Storage::exists($backupFileName)) {
+ $path = Storage::path($backupFileName);
+ $tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
+ instant_scp($path, $tmpPath, $this->server);
+ Storage::delete($backupFileName);
+ $this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
+ } elseif (filled($this->customLocation)) {
+ // Validate the custom location to prevent command injection
+ if (! $this->validateServerPath($this->customLocation)) {
+ $this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
+
+ return true;
+ }
+ $tmpPath = '/tmp/restore_'.$this->resourceUuid;
+ $escapedCustomLocation = escapeshellarg($this->customLocation);
+ $this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
+ } else {
+ $this->dispatch('error', 'The file does not exist or has been deleted.');
+
+ return true;
+ }
+
+ // Copy the restore command to a script file
+ $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
+
+ $restoreCommand = $this->buildRestoreCommand($tmpPath);
+
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
+ $this->importCommands[] = "chmod +x {$scriptPath}";
+ $this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
+
+ $this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
+ $this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
+
+ if (! empty($this->importCommands)) {
+ $activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
+ 'scriptPath' => $scriptPath,
+ 'tmpPath' => $tmpPath,
+ 'container' => $this->container,
+ 'serverId' => $this->server->id,
+ ]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ }
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+
+ return true;
+ } finally {
+ $this->filename = null;
+ $this->importCommands = [];
+ }
+
+ return true;
+ }
+
+ public function loadAvailableS3Storages()
+ {
+ try {
+ $this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
+ ->where('is_usable', true)
+ ->get()
+ ->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
+ ->toArray();
+ } catch (\Throwable $e) {
+ $this->availableS3Storages = [];
+ }
+ }
+
+ public function updatedS3Path($value)
+ {
+ // Reset validation state when path changes
+ $this->s3FileSize = null;
+
+ // Ensure path starts with a slash
+ if ($value !== null && $value !== '') {
+ $this->s3Path = str($value)->trim()->start('/')->value();
+ }
+ }
+
+ public function updatedS3StorageId()
+ {
+ // Reset validation state when storage changes
+ $this->s3FileSize = null;
+ }
+
+ public function checkS3File()
+ {
+ if (! $this->s3StorageId) {
+ $this->dispatch('error', 'Please select an S3 storage.');
+
+ return;
+ }
+
+ if (blank($this->s3Path)) {
+ $this->dispatch('error', 'Please provide an S3 path.');
+
+ return;
+ }
+
+ // Clean the path (remove leading slash if present)
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path early to prevent command injection in subsequent operations
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return;
+ }
+
+ try {
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ // Validate bucket name early
+ if (! $this->validateBucketName($s3Storage->bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return;
+ }
+
+ // Test connection
+ $s3Storage->testConnection();
+
+ // Build S3 disk configuration
+ $disk = Storage::build([
+ 'driver' => 's3',
+ 'region' => $s3Storage->region,
+ 'key' => $s3Storage->key,
+ 'secret' => $s3Storage->secret,
+ 'bucket' => $s3Storage->bucket,
+ 'endpoint' => $s3Storage->endpoint,
+ 'use_path_style_endpoint' => true,
+ ]);
+
+ // Check if file exists
+ if (! $disk->exists($cleanPath)) {
+ $this->dispatch('error', 'File not found in S3. Please check the path.');
+
+ return;
+ }
+
+ // Get file size
+ $this->s3FileSize = $disk->size($cleanPath);
+
+ $this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
+ } catch (\Throwable $e) {
+ $this->s3FileSize = null;
+
+ return handleError($e, $this);
+ }
+ }
+
+ public function restoreFromS3(string $password = ''): bool|string
+ {
+ if (! verifyPasswordConfirmation($password, $this)) {
+ return 'The provided password is incorrect.';
+ }
+
+ $this->authorize('update', $this->resource);
+
+ if (! ValidationPatterns::isValidContainerName($this->container)) {
+ $this->dispatch('error', 'Invalid container name.');
+
+ return true;
+ }
+
+ if (! $this->s3StorageId || blank($this->s3Path)) {
+ $this->dispatch('error', 'Please select S3 storage and provide a path first.');
+
+ return true;
+ }
+
+ if (is_null($this->s3FileSize)) {
+ $this->dispatch('error', 'Please check the file first by clicking "Check File".');
+
+ return true;
+ }
+
+ if (! $this->server) {
+ $this->dispatch('error', 'Server not found. Please refresh the page.');
+
+ return true;
+ }
+
+ try {
+ $this->importRunning = true;
+
+ $s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
+
+ $key = $s3Storage->key;
+ $secret = $s3Storage->secret;
+ $bucket = $s3Storage->bucket;
+ $endpoint = $s3Storage->endpoint;
+
+ // Validate bucket name to prevent command injection
+ if (! $this->validateBucketName($bucket)) {
+ $this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
+
+ return true;
+ }
+
+ // Clean the S3 path
+ $cleanPath = ltrim($this->s3Path, '/');
+
+ // Validate the S3 path to prevent command injection
+ if (! $this->validateS3Path($cleanPath)) {
+ $this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
+
+ return true;
+ }
+
+ // Get helper image
+ $helperImage = config('constants.coolify.helper_image');
+ $latestVersion = getHelperVersion();
+ $fullImageName = "{$helperImage}:{$latestVersion}";
+
+ // Get the database destination network
+ if ($this->resource->getMorphClass() === ServiceDatabase::class) {
+ $destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
+ } else {
+ $destinationNetwork = $this->resource->destination->network ?? 'coolify';
+ }
+
+ // Generate unique names for this operation
+ $containerName = "s3-restore-{$this->resourceUuid}";
+ $helperTmpPath = '/tmp/'.basename($cleanPath);
+ $serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
+ $containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
+ $scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
+
+ $escapedServerTmpPath = escapeshellarg($serverTmpPath);
+ $escapedContainerTmpPath = escapeshellarg($containerTmpPath);
+ $escapedScriptPath = escapeshellarg($scriptPath);
+ $escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
+ $escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
+ $escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
+ $restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
+
+ // Prepare all commands in sequence
+ $commands = [];
+
+ // 1. Clean up any existing helper container and temp files from previous runs
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
+ $commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
+
+ // 2. Start helper container on the database network
+ $commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
+
+ // 3. Configure S3 access in helper container
+ $escapedEndpoint = escapeshellarg($endpoint);
+ $escapedKey = escapeshellarg($key);
+ $escapedSecret = escapeshellarg($secret);
+ $commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
+
+ // 4. Check file exists in S3 (bucket and path already validated above)
+ $escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
+ $commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
+
+ // 5. Download from S3 to helper container (progress shown by default)
+ $escapedHelperTmpPath = escapeshellarg($helperTmpPath);
+ $commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
+
+ // 6. Copy from helper to server, then immediately to database container
+ $commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
+ $commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
+
+ // 7. Cleanup helper container and server temp file immediately (no longer needed)
+ $commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
+ $commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
+
+ // 8. Build and execute restore command inside database container
+ $restoreCommand = $this->buildRestoreCommand($containerTmpPath);
+
+ $restoreCommandBase64 = base64_encode($restoreCommand);
+ $commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
+ $commands[] = "chmod +x {$escapedScriptPath}";
+ $commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
+
+ // 9. Execute restore and cleanup temp files immediately after completion
+ $commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
+ $commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
+
+ // Execute all commands with cleanup event (as safety net for edge cases)
+ $activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
+ 'containerName' => $containerName,
+ 'serverTmpPath' => $serverTmpPath,
+ 'scriptPath' => $scriptPath,
+ 'containerTmpPath' => $containerTmpPath,
+ 'container' => $this->container,
+ 'serverId' => $this->server->id,
+ ]);
+
+ // Track the activity ID
+ $this->activityId = $activity->id;
+
+ // Dispatch activity to the monitor and open slide-over
+ $this->dispatch('activityMonitor', $activity->id);
+ $this->dispatch('databaserestore');
+ $this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
+ } catch (\Throwable $e) {
+ $this->importRunning = false;
+ handleError($e, $this);
+
+ return true;
+ }
+
+ return true;
+ }
+
+ public function buildRestoreCommand(string $tmpPath): string
+ {
+ $escapedTmpPath = escapeshellarg($tmpPath);
+ $morphClass = $this->resource->getMorphClass();
+
+ // Handle ServiceDatabase by checking the database type
+ if ($morphClass === ServiceDatabase::class) {
+ $dbType = $this->resource->databaseType();
+ if (str_contains($dbType, 'mysql')) {
+ $morphClass = 'mysql';
+ } elseif (str_contains($dbType, 'mariadb')) {
+ $morphClass = 'mariadb';
+ } elseif (str_contains($dbType, 'postgres')) {
+ $morphClass = 'postgresql';
+ } elseif (str_contains($dbType, 'mongo')) {
+ $morphClass = 'mongodb';
+ }
+ }
+
+ switch ($morphClass) {
+ case StandaloneMariadb::class:
+ case 'mariadb':
+ $restoreCommand = $this->mariadbRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
+ } else {
+ $restoreCommand .= " < {$escapedTmpPath}";
+ }
+ break;
+ case StandaloneMysql::class:
+ case 'mysql':
+ $restoreCommand = $this->mysqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
+ } else {
+ $restoreCommand .= " < {$escapedTmpPath}";
+ }
+ break;
+ case StandalonePostgresql::class:
+ case 'postgresql':
+ $restoreCommand = $this->postgresqlRestoreCommand;
+ if ($this->dumpAll) {
+ $restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}";
+ } else {
+ $restoreCommand .= " {$escapedTmpPath}";
+ }
+ break;
+ case StandaloneMongodb::class:
+ case 'mongodb':
+ $restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
+ break;
+ default:
+ $restoreCommand = '';
+ }
+
+ return $restoreCommand;
+ }
+}
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index b50f196a8..974803e8d 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -4,11 +4,9 @@ namespace App\Livewire\Project\Database\Keydb;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
@@ -42,25 +40,21 @@ class General extends Component
public ?string $customDockerRunOptions = null;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
public bool $isLogDrainEnabled = false;
- public ?Carbon $certificateValidUntil = null;
-
- public bool $enable_ssl = false;
-
- public function getListeners()
+ public function getListeners(): array
{
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
+ $user = Auth::user();
+ if (! $user) {
+ return [];
+ }
+ $team = $user->currentTeam();
+ if (! $team) {
+ return [];
+ }
return [
- "echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
+ "echo-private:team.{$team->id},DatabaseProxyStopped" => 'databaseProxyStopped',
];
}
@@ -75,12 +69,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -88,24 +76,21 @@ class General extends Component
protected function rules(): array
{
- $baseRules = [
+ return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
- 'keydbPassword' => 'required|string',
+ 'keydbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
- 'dbUrl' => 'nullable|string',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
- 'enable_ssl' => 'boolean',
];
-
- return $baseRules;
}
protected function messages(): array
@@ -114,8 +99,7 @@ class General extends Component
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.',
@@ -142,11 +126,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
- $this->database->enable_ssl = $this->enable_ssl;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -159,9 +139,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
- $this->enable_ssl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
}
}
@@ -210,6 +187,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -218,9 +196,13 @@ class General extends Component
}
}
- public function databaseProxyStopped()
+ public function databaseProxyStopped(): void
{
- $this->syncData();
+ $this->database->refresh();
+ $this->isPublic = $this->database->is_public;
+ $this->publicPort = $this->database->public_port;
+ $this->publicPortTimeout = $this->database->public_port_timeout;
+ $this->dispatch('databaseUpdated');
}
public function submit()
@@ -236,6 +218,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -247,65 +230,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()
- ->where('is_ca_certificate', true)
- ->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Keydb/StatusInfo.php b/app/Livewire/Project/Database/Keydb/StatusInfo.php
new file mode 100644
index 000000000..1e87461cd
--- /dev/null
+++ b/app/Livewire/Project/Database/Keydb/StatusInfo.php
@@ -0,0 +1,26 @@
+currentTeam()->id;
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mariadbRootPassword' => 'required',
- 'mariadbUser' => 'required',
- 'mariadbPassword' => 'required',
- 'mariadbDatabase' => 'required',
+ 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password,
+ ),
+ 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbUser !== $this->database->mariadb_user,
+ ),
+ 'mariadbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password,
+ ),
+ 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database,
+ ),
'mariadbConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@@ -86,7 +72,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
];
}
@@ -97,10 +82,10 @@ class General extends Component
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.',
@@ -125,7 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -139,12 +123,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -168,11 +146,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
- $this->database->enable_ssl = $this->enableSsl;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -188,9 +162,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -226,6 +197,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -262,6 +234,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -270,63 +243,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mariadb/StatusInfo.php b/app/Livewire/Project/Database/Mariadb/StatusInfo.php
new file mode 100644
index 000000000..c6fda37b6
--- /dev/null
+++ b/app/Livewire/Project/Database/Mariadb/StatusInfo.php
@@ -0,0 +1,21 @@
+currentTeam()->id;
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
- 'mongoInitdbRootUsername' => 'required',
- 'mongoInitdbRootPassword' => 'required',
- 'mongoInitdbDatabase' => 'required',
+ 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username,
+ ),
+ 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password,
+ ),
+ 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database,
+ ),
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -85,8 +67,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@@ -97,16 +77,15 @@ class General extends Component
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
- 'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
- 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'),
+ ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
@@ -124,8 +103,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -139,12 +116,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -167,12 +138,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
- $this->database->enable_ssl = $this->enableSsl;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -187,10 +153,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -229,6 +191,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -265,6 +228,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -273,68 +237,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mongodb/StatusInfo.php b/app/Livewire/Project/Database/Mongodb/StatusInfo.php
new file mode 100644
index 000000000..a92a682c9
--- /dev/null
+++ b/app/Livewire/Project/Database/Mongodb/StatusInfo.php
@@ -0,0 +1,51 @@
+ ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
+ 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
+ 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
+ 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for MongoDB connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index cacb4ac49..6b88d735d 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Mysql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -50,36 +47,23 @@ class General extends Component
public ?string $customDockerRunOptions = null;
- public bool $enableSsl = false;
-
- public ?string $sslMode = null;
-
- public ?string $db_url = null;
-
- public ?string $db_url_public = null;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
- ];
- }
-
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mysqlRootPassword' => 'required',
- 'mysqlUser' => 'required',
- 'mysqlPassword' => 'required',
- 'mysqlDatabase' => 'required',
+ 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password,
+ ),
+ 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlUser !== $this->database->mysql_user,
+ ),
+ 'mysqlPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlPassword !== $this->database->mysql_password,
+ ),
+ 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database,
+ ),
'mysqlConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@@ -88,8 +72,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@@ -100,17 +82,16 @@ class General extends Component
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'mysqlRootPassword.required' => 'The Root Password field is required.',
- 'mysqlUser.required' => 'The MySQL User field is required.',
- 'mysqlPassword.required' => 'The MySQL Password field is required.',
- 'mysqlDatabase.required' => 'The MySQL Database field is required.',
+ ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'),
+ ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
@@ -129,8 +110,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -144,12 +123,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -173,12 +146,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
- $this->database->enable_ssl = $this->enableSsl;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -194,10 +162,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -233,6 +197,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -269,6 +234,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -277,68 +243,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Mysql/StatusInfo.php b/app/Livewire/Project/Database/Mysql/StatusInfo.php
new file mode 100644
index 000000000..5fbbc1583
--- /dev/null
+++ b/app/Livewire/Project/Database/Mysql/StatusInfo.php
@@ -0,0 +1,51 @@
+ ['title' => 'Prefer secure connections', 'label' => 'Prefer (secure)'],
+ 'REQUIRED' => ['title' => 'Require secure connections', 'label' => 'Require (secure)'],
+ 'VERIFY_CA' => ['title' => 'Verify CA certificate', 'label' => 'Verify CA (secure)'],
+ 'VERIFY_IDENTITY' => ['title' => 'Verify full certificate', 'label' => 'Verify Full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for MySQL connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 22e350683..4e89e8b62 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Postgresql;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -54,41 +51,29 @@ class General extends Component
public ?string $customDockerRunOptions = null;
- public bool $enableSsl = false;
-
- public ?string $sslMode = null;
-
public string $new_filename;
public string $new_content;
- public ?string $db_url = null;
-
- public ?string $db_url_public = null;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
- 'save_init_script',
- 'delete_init_script',
- ];
- }
+ protected $listeners = [
+ 'save_init_script',
+ 'delete_init_script',
+ ];
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'postgresUser' => 'required',
- 'postgresPassword' => 'required',
- 'postgresDb' => 'required',
+ 'postgresUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresUser !== $this->database->postgres_user,
+ ),
+ 'postgresPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->postgresPassword !== $this->database->postgres_password,
+ ),
+ 'postgresDb' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresDb !== $this->database->postgres_db,
+ ),
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
@@ -100,8 +85,6 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@@ -112,16 +95,15 @@ class General extends Component
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'postgresUser.required' => 'The Postgres User field is required.',
- 'postgresPassword.required' => 'The Postgres Password field is required.',
- 'postgresDb.required' => 'The Postgres Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'),
+ ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
@@ -142,8 +124,6 @@ class General extends Component
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -157,12 +137,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -188,12 +162,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
- $this->database->enable_ssl = $this->enableSsl;
- $this->database->ssl_mode = $this->sslMode;
$this->database->save();
-
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -211,10 +180,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->sslMode = $this->database->ssl_mode;
- $this->db_url = $this->database->internal_db_url;
- $this->db_url_public = $this->database->external_db_url;
}
}
@@ -237,68 +202,6 @@ class General extends Component
}
}
- public function updatedSslMode()
- {
- $this->instantSaveSSL();
- }
-
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates have been regenerated. Please restart the database for changes to take effect.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
public function instantSave()
{
try {
@@ -324,6 +227,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -352,9 +256,14 @@ class General extends Component
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($oldScript['filename'], 'init script filename');
- $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
+ // New filename is user-supplied — must be safe before accepting the rename.
+ validateFilenameSafe($script['filename'], 'init script filename');
+
+ // Old filename may be a legacy value written before this validation existed.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
+ // any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
+ $old_filename = basename($oldScript['filename']);
+ $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
@@ -398,9 +307,11 @@ class General extends Component
$configuration_dir = database_configuration_dir().'/'.$container_name;
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($script['filename'], 'init script filename');
- $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
+ // Allow deletion of legacy rows with unsafe filenames so operators can clean up.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the
+ // shell invocation safe regardless of the stored value.
+ $safe_filename = basename($script['filename']);
+ $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
@@ -437,8 +348,8 @@ class General extends Component
]);
try {
- // Validate filename to prevent command injection
- validateShellSafePath($this->new_filename, 'init script filename');
+ // Validate filename to prevent path traversal and command injection
+ validateFilenameSafe($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -480,6 +391,7 @@ class General extends Component
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Database/Postgresql/StatusInfo.php b/app/Livewire/Project/Database/Postgresql/StatusInfo.php
new file mode 100644
index 000000000..cc27b61bb
--- /dev/null
+++ b/app/Livewire/Project/Database/Postgresql/StatusInfo.php
@@ -0,0 +1,52 @@
+ ['title' => 'Allow insecure connections', 'label' => 'allow (insecure)'],
+ 'prefer' => ['title' => 'Prefer secure connections', 'label' => 'prefer (secure)'],
+ 'require' => ['title' => 'Require secure connections', 'label' => 'require (secure)'],
+ 'verify-ca' => ['title' => 'Verify CA certificate', 'label' => 'verify-ca (secure)'],
+ 'verify-full' => ['title' => 'Verify full certificate', 'label' => 'verify-full (secure)'],
+ ];
+ }
+
+ protected function sslModeHelper(): string
+ {
+ return 'Choose the SSL verification mode for PostgreSQL connections';
+ }
+
+ protected function afterRefresh(): void
+ {
+ $this->sslMode = $this->database->ssl_mode;
+ }
+
+ protected function applyExtraSslAttributes(): void
+ {
+ $this->database->ssl_mode = $this->sslMode;
+ }
+
+ public function updatedSslMode(): void
+ {
+ $this->instantSaveSSL();
+ }
+}
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index 3c32a6192..aff7b7afa 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -4,14 +4,11 @@ namespace App\Livewire\Project\Database\Redis;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
-use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
-use Carbon\Carbon;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class General extends Component
@@ -48,25 +45,9 @@ class General extends Component
public string $redisVersion;
- public ?string $dbUrl = null;
-
- public ?string $dbUrlPublic = null;
-
- public bool $enableSsl = false;
-
- public ?Carbon $certificateValidUntil = null;
-
- public function getListeners()
- {
- $userId = Auth::id();
- $teamId = Auth::user()->currentTeam()->id;
-
- return [
- "echo-private:user.{$userId},DatabaseStatusChanged" => 'refresh',
- "echo-private:team.{$teamId},ServiceChecked" => 'refresh',
- 'envsUpdated' => 'refresh',
- ];
- }
+ protected $listeners = [
+ 'envsUpdated' => 'refresh',
+ ];
protected function rules(): array
{
@@ -81,9 +62,12 @@ class General extends Component
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'redisUsername' => 'required',
- 'redisPassword' => 'required',
- 'enableSsl' => 'boolean',
+ 'redisUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->redisUsername !== $this->database->redis_username,
+ ),
+ 'redisPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->redisPassword !== $this->database->redis_password,
+ ),
];
}
@@ -100,8 +84,8 @@ class General extends Component
'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'),
]
);
}
@@ -118,7 +102,6 @@ class General extends Component
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -132,12 +115,6 @@ class General extends Component
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -157,11 +134,7 @@ class General extends Component
$this->database->public_port_timeout = $this->publicPortTimeout ?: null;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
- $this->database->enable_ssl = $this->enableSsl;
$this->database->save();
-
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -173,9 +146,6 @@ class General extends Component
$this->publicPortTimeout = $this->database->public_port_timeout;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
- $this->enableSsl = $this->database->enable_ssl;
- $this->dbUrl = $this->database->internal_db_url;
- $this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password;
@@ -223,6 +193,7 @@ class General extends Component
);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -255,6 +226,7 @@ class General extends Component
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
+ $this->dispatch('databaseUpdated');
} catch (\Throwable $e) {
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
@@ -263,63 +235,6 @@ class General extends Component
}
}
- public function instantSaveSSL()
- {
- try {
- $this->authorize('update', $this->database);
-
- $this->syncData(true);
- $this->dispatch('success', 'SSL configuration updated.');
- } catch (Exception $e) {
- return handleError($e, $this);
- }
- }
-
- public function regenerateSslCertificate()
- {
- try {
- $this->authorize('update', $this->database);
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if (! $existingCert) {
- $this->dispatch('error', 'No existing SSL certificate found for this database.');
-
- return;
- }
-
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
-
- if (! $caCert) {
- $this->server->generateCaCertificate();
- $caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
- }
-
- if (! $caCert) {
- $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
-
- return;
- }
-
- SslHelper::generateSslCertificate(
- commonName: $existingCert->common_name,
- subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
- resourceType: $existingCert->resource_type,
- resourceId: $existingCert->resource_id,
- serverId: $existingCert->server_id,
- caCert: $caCert->ssl_certificate,
- caKey: $caCert->ssl_private_key,
- configurationDir: $existingCert->configuration_dir,
- mountPath: $existingCert->mount_path,
- isPemKeyFileRequired: true,
- );
-
- $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
- } catch (Exception $e) {
- handleError($e, $this);
- }
- }
-
public function refresh(): void
{
$this->database->refresh();
diff --git a/app/Livewire/Project/Database/Redis/StatusInfo.php b/app/Livewire/Project/Database/Redis/StatusInfo.php
new file mode 100644
index 000000000..2e784e2c0
--- /dev/null
+++ b/app/Livewire/Project/Database/Redis/StatusInfo.php
@@ -0,0 +1,21 @@
+environmentName = Environment::findOrFail($this->environment_id)->name;
- $this->parameters = get_route_parameters();
- } catch (\Exception $e) {
- return handleError($e, $this);
- }
+ $this->parameters = get_route_parameters();
+ $this->environmentName = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id)->name;
}
public function delete()
@@ -33,7 +31,7 @@ class DeleteEnvironment extends Component
$this->validate([
'environment_id' => 'required|int',
]);
- $environment = Environment::findOrFail($this->environment_id);
+ $environment = Environment::ownedByCurrentTeam()->findOrFail($this->environment_id);
$this->authorize('delete', $environment);
if ($environment->isEmpty()) {
diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php
index 2b92902c6..2cf0659bf 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -31,7 +29,6 @@ class DockerCompose extends Component
public function submit()
{
- $server_id = $this->query['server_id'];
try {
$this->validate([
'dockerComposeRaw' => 'required',
@@ -44,20 +41,17 @@ class DockerCompose extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 268333d07..737806cb8 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -4,9 +4,8 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Services\DockerImageParser;
+use App\Support\ValidationPatterns;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -83,8 +82,8 @@ class DockerImage extends Component
public function submit()
{
$this->validate([
- 'imageName' => ['required', 'string'],
- 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
+ 'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
+ 'imageTag' => ValidationPatterns::dockerImageTagRules(),
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
@@ -111,13 +110,10 @@ class DockerImage extends Component
$parser = new DockerImageParser;
$parser->parse($dockerImage);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 0222008b0..1c9c8e896 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -5,12 +5,11 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class GithubPrivateRepository extends Component
@@ -31,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id;
+ #[Locked]
public int $selected_github_app_id;
public string $selected_repository_owner;
@@ -39,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main';
- public string $token;
-
public $repositories;
public int $total_repositories_count = 0;
@@ -73,7 +71,10 @@ class GithubPrivateRepository extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->repositories = $this->branches = collect();
- $this->github_apps = GithubApp::private();
+ $this->github_apps = GithubApp::ownedByCurrentTeam()
+ ->where('is_public', false)
+ ->whereNotNull('app_id')
+ ->get();
}
public function updatedSelectedRepositoryId(): void
@@ -83,9 +84,11 @@ class GithubPrivateRepository extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
@@ -96,22 +99,25 @@ class GithubPrivateRepository extends Component
}
}
- public function loadRepositories($github_app_id)
+ public function loadRepositories(int $github_app_id): void
{
$this->repositories = collect();
$this->branches = collect();
$this->total_branches_count = 0;
$this->page = 1;
$this->selected_github_app_id = $github_app_id;
- $this->github_app = GithubApp::where('id', $github_app_id)->first();
- $this->token = generateGithubInstallationToken($this->github_app);
- $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
+ $this->github_app = GithubApp::ownedByCurrentTeam()
+ ->where('is_public', false)
+ ->whereNotNull('app_id')
+ ->findOrFail($github_app_id);
+ $token = generateGithubInstallationToken($this->github_app);
+ $repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) {
while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++;
- $repositories = loadRepositoryByPage($this->github_app, $this->token, $this->page);
+ $repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
}
@@ -142,7 +148,9 @@ class GithubPrivateRepository extends Component
protected function loadBranchByPage()
{
- $response = Http::GitHub($this->github_app->api_url, $this->token)
+ $token = generateGithubInstallationToken($this->github_app);
+
+ $response = Http::GitHub($this->github_app->api_url, $token)
->timeout(20)
->retry(3, 200, throw: false)
->get("/repos/{$this->selected_repository_owner}/{$this->selected_repository_repo}/branches", [
@@ -178,13 +186,10 @@ class GithubPrivateRepository extends Component
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index f8642d6fc..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -7,8 +7,6 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -96,9 +94,11 @@ class GithubPrivateRepositoryDeployKey extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
@@ -130,13 +130,10 @@ class GithubPrivateRepositoryDeployKey extends Component
{
$this->validate();
try {
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index dbfa15a55..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -7,8 +7,6 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -34,8 +32,6 @@ class PublicGitRepository extends Component
public bool $isStatic = false;
- public bool $checkCoolifyConfig = true;
-
public ?string $publish_directory = null;
// In case of docker compose
@@ -100,9 +96,11 @@ class PublicGitRepository extends Component
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->isStatic) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->isStatic = false;
@@ -284,16 +282,13 @@ class PublicGitRepository extends Component
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
- $destination_uuid = $this->query['destination'];
+ $destination_uuid = $this->query['destination'] ?? null;
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
@@ -371,12 +366,6 @@ class PublicGitRepository extends Component
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;
$application->save();
- if ($this->checkCoolifyConfig) {
- // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
- // if ($config) {
- // $application->setConfig($config);
- // }
- }
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php
index 1073157e6..f07948dba 100644
--- a/app/Livewire/Project/New/SimpleDockerfile.php
+++ b/app/Livewire/Project/New/SimpleDockerfile.php
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -35,13 +33,10 @@ CMD ["nginx", "-g", "daemon off;"]
$this->validate([
'dockerfile' => 'required',
]);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index 966c66a14..4619ddf37 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Resource;
use App\Models\EnvironmentVariable;
use App\Models\Service;
-use App\Models\StandaloneDocker;
use Livewire\Component;
class Create extends Component
@@ -18,7 +17,6 @@ class Create extends Component
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
- $server_id = request()->query('server_id');
$database_image = request()->query('database_image');
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
@@ -30,7 +28,11 @@ class Create extends Component
if (! $environment) {
return redirect()->route('dashboard');
}
- if (isset($type) && isset($destination_uuid) && isset($server_id)) {
+ if (isset($type) && isset($destination_uuid)) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
+ return redirect()->route('dashboard');
+ }
$services = get_service_templates();
if (in_array($type, DATABASE_TYPES)) {
@@ -44,23 +46,23 @@ class Create extends Component
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
- destinationUuid: $destination_uuid,
+ destination: $destination,
databaseImage: $database_image
);
} elseif ($type->value() === 'redis') {
- $database = create_standalone_redis($environment->id, $destination_uuid);
+ $database = create_standalone_redis($environment->id, $destination);
} elseif ($type->value() === 'mongodb') {
- $database = create_standalone_mongodb($environment->id, $destination_uuid);
+ $database = create_standalone_mongodb($environment->id, $destination);
} elseif ($type->value() === 'mysql') {
- $database = create_standalone_mysql($environment->id, $destination_uuid);
+ $database = create_standalone_mysql($environment->id, $destination);
} elseif ($type->value() === 'mariadb') {
- $database = create_standalone_mariadb($environment->id, $destination_uuid);
+ $database = create_standalone_mariadb($environment->id, $destination);
} elseif ($type->value() === 'keydb') {
- $database = create_standalone_keydb($environment->id, $destination_uuid);
+ $database = create_standalone_keydb($environment->id, $destination);
} elseif ($type->value() === 'dragonfly') {
- $database = create_standalone_dragonfly($environment->id, $destination_uuid);
+ $database = create_standalone_dragonfly($environment->id, $destination);
} elseif ($type->value() === 'clickhouse') {
- $database = create_standalone_clickhouse($environment->id, $destination_uuid);
+ $database = create_standalone_clickhouse($environment->id, $destination);
}
return redirect()->route('project.database.configuration', [
@@ -69,7 +71,7 @@ class Create extends Component
'database_uuid' => $database->uuid,
]);
}
- if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
+ if ($type->startsWith('one-click-service-')) {
$oneClickServiceName = $type->after('one-click-service-')->value();
$oneClickService = data_get($services, "$oneClickServiceName.compose");
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
@@ -79,12 +81,11 @@ class Create extends Component
});
}
if ($oneClickService) {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first();
$service_payload = [
'docker_compose_raw' => base64_decode($oneClickService),
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php
index 2d69ceb12..caa19042b 100644
--- a/app/Livewire/Project/Service/Configuration.php
+++ b/app/Livewire/Project/Service/Configuration.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Service;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class Configuration extends Component
@@ -27,16 +26,10 @@ class Configuration extends Component
public array $parameters;
- public function getListeners()
- {
- $teamId = Auth::user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
- 'refreshServices' => 'refreshServices',
- 'refresh' => 'refreshServices',
- ];
- }
+ protected $listeners = [
+ 'refreshServices' => 'refreshServices',
+ 'refresh' => 'refreshServices',
+ ];
public function render()
{
@@ -51,7 +44,7 @@ class Configuration extends Component
$this->query = request()->query();
$project = currentTeam()
->projects()
- ->select('id', 'uuid', 'team_id')
+ ->select('id', 'uuid', 'name', 'team_id')
->where('uuid', request()->route('project_uuid'))
->firstOrFail();
$environment = $project->environments()
@@ -105,18 +98,4 @@ class Configuration extends Component
return handleError($e, $this);
}
}
-
- public function serviceChecked()
- {
- try {
- $this->service->applications->each(function ($application) {
- $application->refresh();
- });
- $this->service->databases->each(function ($database) {
- $database->refresh();
- });
- } catch (\Exception $e) {
- return handleError($e, $this);
- }
- }
}
diff --git a/app/Livewire/Project/Service/DatabaseBackups.php b/app/Livewire/Project/Service/DatabaseBackups.php
index 826a6c1ff..883441ecb 100644
--- a/app/Livewire/Project/Service/DatabaseBackups.php
+++ b/app/Livewire/Project/Service/DatabaseBackups.php
@@ -28,10 +28,16 @@ class DatabaseBackups extends Component
try {
$this->parameters = get_route_parameters();
$this->query = request()->query();
- $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
- if (! $this->service) {
- return redirect()->route('dashboard');
- }
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', $this->parameters['project_uuid'])
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', $this->parameters['environment_uuid'])
+ ->firstOrFail();
+ $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 844e37854..2f1a229b4 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -63,13 +63,16 @@ class FileStorage extends Component
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
+ if ($this->fileStorage->is_too_large) {
+ return;
+ }
$this->validate();
// Sync to model
@@ -172,6 +175,12 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
+
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@@ -197,6 +206,11 @@ class FileStorage extends Component
public function instantSave(): void
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
diff --git a/app/Livewire/Project/Service/Heading.php b/app/Livewire/Project/Service/Heading.php
index c8a08d8f9..60273ab23 100644
--- a/app/Livewire/Project/Service/Heading.php
+++ b/app/Livewire/Project/Service/Heading.php
@@ -7,12 +7,15 @@ use App\Actions\Service\StartService;
use App\Actions\Service\StopService;
use App\Enums\ProcessStatus;
use App\Models\Service;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
use Spatie\Activitylog\Models\Activity;
class Heading extends Component
{
+ use AuthorizesRequests;
+
public Service $service;
public array $parameters;
@@ -27,6 +30,8 @@ class Heading extends Component
public function mount()
{
+ $this->authorizeService('view');
+
if (str($this->service->status)->contains('running') && is_null($this->service->config_hash)) {
$this->service->isConfigurationChanged(true);
$this->dispatch('configurationChanged');
@@ -47,6 +52,8 @@ class Heading extends Component
public function checkStatus()
{
+ $this->authorizeService('view');
+
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@@ -61,6 +68,8 @@ class Heading extends Component
public function serviceChecked()
{
+ $this->authorizeService('view');
+
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@@ -82,6 +91,8 @@ class Heading extends Component
public function checkDeployments()
{
+ $this->authorizeService('view');
+
try {
$activity = Activity::where('properties->type_uuid', $this->service->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
@@ -99,12 +110,16 @@ class Heading extends Component
public function start()
{
+ $this->authorizeService('deploy');
+
$activity = StartService::run($this->service, pullLatestImages: true);
$this->dispatch('activityMonitor', $activity->id);
}
public function forceDeploy()
{
+ $this->authorizeService('deploy');
+
try {
$activities = Activity::where('properties->type_uuid', $this->service->uuid)
->where(function ($q) {
@@ -124,6 +139,8 @@ class Heading extends Component
public function stop()
{
+ $this->authorizeService('stop');
+
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@@ -133,6 +150,8 @@ class Heading extends Component
public function restart()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -145,6 +164,8 @@ class Heading extends Component
public function pullAndRestartEvent()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -155,6 +176,15 @@ class Heading extends Component
$this->dispatch('activityMonitor', $activity->id);
}
+ private function authorizeService(string $ability): void
+ {
+ $this->service = Service::ownedByCurrentTeam()
+ ->whereKey($this->service->getKey())
+ ->firstOrFail();
+
+ $this->authorize($ability, $this->service);
+ }
+
public function render()
{
return view('livewire.project.service.heading', [
diff --git a/app/Livewire/Project/Service/Index.php b/app/Livewire/Project/Service/Index.php
index cb2d977bc..12c0edbca 100644
--- a/app/Livewire/Project/Service/Index.php
+++ b/app/Livewire/Project/Service/Index.php
@@ -108,10 +108,16 @@ class Index extends Component
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
- $this->service = Service::whereUuid($this->parameters['service_uuid'])->first();
- if (! $this->service) {
- return redirect()->route('dashboard');
- }
+ $project = currentTeam()
+ ->projects()
+ ->select('id', 'uuid', 'team_id')
+ ->where('uuid', $this->parameters['project_uuid'])
+ ->firstOrFail();
+ $environment = $project->environments()
+ ->select('id', 'uuid', 'name', 'project_id')
+ ->where('uuid', $this->parameters['environment_uuid'])
+ ->firstOrFail();
+ $this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
diff --git a/app/Livewire/Project/Service/ResourceCard.php b/app/Livewire/Project/Service/ResourceCard.php
new file mode 100644
index 000000000..fd27f60c3
--- /dev/null
+++ b/app/Livewire/Project/Service/ResourceCard.php
@@ -0,0 +1,66 @@
+currentTeam();
+ if (! $team) {
+ return [];
+ }
+
+ return [
+ "echo-private:team.{$team->id},ServiceChecked" => 'refreshResource',
+ ];
+ }
+
+ public function refreshResource(): void
+ {
+ $this->resource->refresh();
+ }
+
+ public function restart(): void
+ {
+ try {
+ $this->authorize('update', $this->service);
+ $this->resource->restart();
+ $message = $this->resource instanceof ServiceApplication
+ ? 'Service application restarted successfully.'
+ : 'Service database restarted successfully.';
+ $this->dispatch('success', $message);
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.service.resource-card', [
+ 'isApplication' => $this->resource instanceof ServiceApplication,
+ 'isDatabase' => $this->resource instanceof ServiceDatabase,
+ ]);
+ }
+}
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 433c2b13c..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -69,7 +69,11 @@ class Storage extends Component
public function refreshStorages()
{
- $this->fileStorage = $this->resource->fileStorages()->get();
+ $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
+ if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
+ $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+ }
+ });
$this->resource->load('persistentStorages.resource');
}
@@ -106,8 +110,12 @@ class Storage extends Component
$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/ConfigurationChecker.php b/app/Livewire/Project/Shared/ConfigurationChecker.php
index ce9ce7780..43bf3140b 100644
--- a/app/Livewire/Project/Shared/ConfigurationChecker.php
+++ b/app/Livewire/Project/Shared/ConfigurationChecker.php
@@ -12,15 +12,18 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use Illuminate\Contracts\View\View;
use Livewire\Component;
class ConfigurationChecker extends Component
{
public bool $isConfigurationChanged = false;
+ public array $configurationDiff = [];
+
public Application|Service|StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource;
- public function getListeners()
+ public function getListeners(): array
{
$teamId = auth()->user()->currentTeam()->id;
@@ -30,18 +33,71 @@ class ConfigurationChecker extends Component
];
}
- public function mount()
+ public function mount(): void
{
$this->configurationChanged();
}
- public function render()
+ public function render(): View
{
return view('livewire.project.shared.configuration-checker');
}
- public function configurationChanged()
+ public function refreshConfigurationChanges(): void
{
+ $this->configurationChanged();
+ }
+
+ /**
+ * Members must never see environment variable values, so redact every
+ * environment-section change before it is serialized to the browser.
+ *
+ * @param array> $changes
+ * @return array>
+ */
+ private function redactEnvironmentChanges(array $changes, bool $redact): array
+ {
+ if (! $redact) {
+ return $changes;
+ }
+
+ return collect($changes)
+ ->map(function (array $change): array {
+ if (data_get($change, 'section') !== 'environment') {
+ return $change;
+ }
+
+ $change['old_display_value'] = data_get($change, 'old_display_value') === '-' ? '-' : '••••••••';
+ $change['new_display_value'] = data_get($change, 'new_display_value') === '-' ? '-' : '••••••••';
+ $change['old_full_value'] = null;
+ $change['new_full_value'] = null;
+ $change['expandable'] = false;
+ $change['display_summary'] = data_get($change, 'type') === 'changed' ? 'Changed' : null;
+
+ return $change;
+ })
+ ->all();
+ }
+
+ public function configurationChanged(): void
+ {
+ $this->resource->refresh();
+
+ if ($this->resource instanceof Application) {
+ $diff = $this->resource->pendingDeploymentConfigurationDiff();
+ // Fail closed: only owners/admins may see unlocked env values.
+ $redactEnvironment = ! (bool) auth()->user()?->isAdmin();
+
+ $array = $diff->toArray();
+ $array['changes'] = $this->redactEnvironmentChanges($array['changes'] ?? [], $redactEnvironment);
+
+ $this->isConfigurationChanged = $diff->isChanged();
+ $this->configurationDiff = $array;
+
+ return;
+ }
+
$this->isConfigurationChanged = $this->resource->isConfigurationChanged();
+ $this->configurationDiff = [];
}
}
diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php
index 363471760..715ce82a7 100644
--- a/app/Livewire/Project/Shared/Destination.php
+++ b/app/Livewire/Project/Shared/Destination.php
@@ -110,15 +110,27 @@ class Destination extends Component
public function promote(int $network_id, int $server_id)
{
- $main_destination = $this->resource->destination;
- $this->resource->update([
- 'destination_id' => $network_id,
- 'destination_type' => StandaloneDocker::class,
- ]);
- $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
- $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
- $this->refreshServers();
- $this->resource->refresh();
+ try {
+ $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
+ $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
+ $this->authorize('update', $this->resource);
+
+ $this->resource->getConnection()->transaction(function () use ($network, $server) {
+ $main_destination = $this->resource->destination;
+ $this->resource->update([
+ 'destination_id' => $network->id,
+ 'destination_type' => StandaloneDocker::class,
+ ]);
+ $this->resource->additional_networks()
+ ->wherePivot('server_id', $server->id)
+ ->detach($network->id);
+ $this->resource->additional_networks()->attach($main_destination->id, ['server_id' => $main_destination->server->id]);
+ });
+ $this->resource->refresh();
+ $this->refreshServers();
+ } catch (\Exception $e) {
+ return handleError($e, $this);
+ }
}
public function refreshServers()
@@ -130,8 +142,16 @@ class Destination extends Component
public function addServer(int $network_id, int $server_id)
{
- $this->resource->additional_networks()->attach($network_id, ['server_id' => $server_id]);
- $this->dispatch('refresh');
+ try {
+ $server = Server::ownedByCurrentTeam()->findOrFail($server_id);
+ $network = StandaloneDocker::ownedByCurrentTeam()->where('server_id', $server->id)->findOrFail($network_id);
+ $this->authorize('update', $this->resource);
+
+ $this->resource->additional_networks()->attach($network->id, ['server_id' => $server->id]);
+ $this->dispatch('refresh');
+ } catch (\Exception $e) {
+ return handleError($e, $this);
+ }
}
public function removeServer(int $network_id, int $server_id, $password, $selectedActions = [])
@@ -148,7 +168,9 @@ class Destination extends Component
}
$server = Server::ownedByCurrentTeam()->findOrFail($server_id);
StopApplicationOneServer::run($this->resource, $server);
- $this->resource->additional_networks()->detach($network_id, ['server_id' => $server_id]);
+ $this->resource->additional_networks()
+ ->wherePivot('server_id', $server_id)
+ ->detach($network_id);
$this->loadData();
$this->dispatch('refresh');
ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id'));
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Add.php b/app/Livewire/Project/Shared/EnvironmentVariable/Add.php
index 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 @@ class Add extends Component
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -116,12 +129,12 @@ class Add extends Component
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -131,7 +144,7 @@ class Add extends Component
$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 @@ class Add extends Component
$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 @@ class Add extends Component
// 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 @@ class Add extends Component
$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 @@ class Add extends Component
// 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 @@ class Add extends Component
$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 @@ class Add extends Component
public function submit()
{
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index f250a860b..53b55009e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -2,7 +2,9 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\EnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -38,7 +40,7 @@ class All extends Component
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
- $resourceWithPreviews = [\App\Models\Application::class];
+ $resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@@ -194,7 +196,7 @@ class All extends Component
private function updateOrder()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@@ -206,7 +208,7 @@ class All extends Component
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@@ -221,7 +223,7 @@ class All extends Component
private function handleBulkSubmit()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -241,7 +243,7 @@ class All extends Component
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@@ -267,6 +269,7 @@ class All extends Component
private function handleSingleSubmit($data)
{
+ $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@@ -334,6 +337,23 @@ class All extends Component
return $variablesToDelete->count();
}
+ private function normalizeEnvironmentVariables(array $variables): array
+ {
+ $normalizedVariables = [];
+
+ foreach ($variables as $key => $data) {
+ $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
+
+ if (array_key_exists($normalizedKey, $normalizedVariables)) {
+ throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
+ }
+
+ $normalizedVariables[$normalizedKey] = $data;
+ }
+
+ return $normalizedVariables;
+ }
+
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 4e8521f27..26369852e 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -2,12 +2,17 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable;
+use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
use App\Models\Project;
+use App\Models\Server;
+use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
+use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableAnalyzer;
use App\Traits\EnvironmentVariableProtection;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -64,23 +69,31 @@ class Show extends Component
'compose_loaded' => '$refresh',
];
- protected $rules = [
- 'key' => 'required|string',
- 'value' => 'nullable',
- 'comment' => 'nullable|string|max:256',
- 'is_multiline' => 'required|boolean',
- 'is_literal' => 'required|boolean',
- 'is_shown_once' => 'required|boolean',
- 'is_runtime' => 'required|boolean',
- 'is_buildtime' => 'required|boolean',
- 'real_value' => 'nullable',
- 'is_required' => 'required|boolean',
- ];
+ protected function rules(): array
+ {
+ return [
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
+ 'value' => 'nullable',
+ 'comment' => 'nullable|string|max:256',
+ 'is_multiline' => 'required|boolean',
+ 'is_literal' => 'required|boolean',
+ 'is_shown_once' => 'required|boolean',
+ 'is_runtime' => 'required|boolean',
+ 'is_buildtime' => 'required|boolean',
+ 'real_value' => 'nullable',
+ 'is_required' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return ValidationPatterns::environmentVariableKeyMessages('key');
+ }
public function mount()
{
$this->syncData();
- if ($this->env->getMorphClass() === \App\Models\SharedEnvironmentVariable::class) {
+ if ($this->env->getMorphClass() === SharedEnvironmentVariable::class) {
$this->isSharedVariable = true;
}
$this->parameters = get_route_parameters();
@@ -108,9 +121,11 @@ class Show extends Component
public function syncData(bool $toModel = false)
{
if ($toModel) {
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+
if ($this->isSharedVariable) {
$this->validate([
- 'key' => 'required|string',
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@@ -233,7 +248,7 @@ class Show extends Component
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -264,12 +279,12 @@ class Show extends Component
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -279,7 +294,7 @@ class Show extends Component
$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();
@@ -289,7 +304,7 @@ class Show extends Component
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -297,7 +312,7 @@ class Show extends Component
// 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();
@@ -308,7 +323,7 @@ class Show extends Component
$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
}
}
@@ -316,7 +331,7 @@ class Show extends Component
// 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();
@@ -327,7 +342,7 @@ class Show extends Component
$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/ResourceDetails.php b/app/Livewire/Project/Shared/ResourceDetails.php
new file mode 100644
index 000000000..8a4117c39
--- /dev/null
+++ b/app/Livewire/Project/Shared/ResourceDetails.php
@@ -0,0 +1,91 @@
+authorize('view', $this->resource);
+
+ $environment = $this->resource->environment ?? null;
+ if ($environment) {
+ $this->environment_uuid = $environment->uuid;
+ $this->environment_name = $environment->name;
+ $project = $environment->project ?? null;
+ if ($project) {
+ $this->project_uuid = $project->uuid;
+ $this->project_name = $project->name;
+ }
+ }
+
+ $server = $this->resolveServer();
+ if ($server) {
+ $this->server_uuid = $server->uuid;
+ $this->server_name = $server->name;
+ }
+
+ if ($this->resource instanceof Service) {
+ $this->stack_applications = $this->resource->applications
+ ->map(fn ($app) => [
+ 'name' => $app->human_name ?: $app->name,
+ 'uuid' => $app->uuid,
+ ])
+ ->values()
+ ->all();
+
+ $this->stack_databases = $this->resource->databases
+ ->map(fn ($db) => [
+ 'name' => $db->human_name ?: $db->name,
+ 'uuid' => $db->uuid,
+ ])
+ ->values()
+ ->all();
+ }
+ }
+
+ private function resolveServer()
+ {
+ try {
+ if (isset($this->resource->destination) && $this->resource->destination && isset($this->resource->destination->server)) {
+ return $this->resource->destination->server;
+ }
+ if (method_exists($this->resource, 'server') && $this->resource->server) {
+ return $this->resource->server;
+ }
+ } catch (\Throwable $e) {
+ return null;
+ }
+
+ return null;
+ }
+
+ public function render()
+ {
+ return view('livewire.project.shared.resource-details');
+ }
+}
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index f4813dd4c..2a8747c33 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -58,10 +58,9 @@ class ResourceOperations extends Component
{
$this->authorize('update', $this->resource);
- $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
- $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
- $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index eee5a0776..2aaca5e6f 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\LocalPersistentVolume;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -31,19 +32,33 @@ class Show extends Component
public bool $isPreviewSuffixEnabled = true;
- protected $rules = [
- 'name' => 'required|string',
- 'mountPath' => 'required|string',
- 'hostPath' => 'string|nullable',
- 'isPreviewSuffixEnabled' => 'required|boolean',
- ];
-
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
'hostPath' => 'host',
];
+ protected function rules(): array
+ {
+ return [
+ 'name' => ValidationPatterns::volumeNameRules(),
+ 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'isPreviewSuffixEnabled' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return array_merge(
+ ValidationPatterns::volumeNameMessages(),
+ [
+ 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
+ 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
+ ]
+ );
+ }
+
/**
* Sync data between component properties and model
*
diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php
index bbc2b3e66..db65cdaad 100644
--- a/app/Livewire/Project/Shared/Terminal.php
+++ b/app/Livewire/Project/Shared/Terminal.php
@@ -12,6 +12,8 @@ class Terminal extends Component
{
public bool $hasShell = true;
+ public bool $isTerminalConnected = false;
+
private function checkShellAvailability(Server $server, string $container): bool
{
$escapedContainer = escapeshellarg($container);
@@ -65,12 +67,20 @@ class Terminal extends Component
$dockerCommand = "sudo {$dockerCommand}";
}
- $command = SshMultiplexingHelper::generateSshCommand($server, $dockerCommand);
+ $command = SshMultiplexingHelper::generateSshCommand(
+ $server,
+ $dockerCommand,
+ commandTimeout: (int) config('constants.terminal.command_timeout')
+ );
} else {
$shellCommand = 'PATH=$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin && '.
'if [ -f ~/.profile ]; then . ~/.profile; fi && '.
'if [ -n "$SHELL" ] && [ -x "$SHELL" ]; then exec $SHELL; else sh; fi';
- $command = SshMultiplexingHelper::generateSshCommand($server, $shellCommand);
+ $command = SshMultiplexingHelper::generateSshCommand(
+ $server,
+ $shellCommand,
+ commandTimeout: (int) config('constants.terminal.command_timeout')
+ );
}
// ssh command is sent back to frontend then to websocket
// this is done because the websocket connection is not available here
@@ -84,6 +94,23 @@ class Terminal extends Component
$this->dispatch('send-back-command', $command);
}
+ #[On('terminalConnected')]
+ public function markTerminalConnected(): void
+ {
+ $this->isTerminalConnected = true;
+ }
+
+ #[On('terminalDisconnected')]
+ public function markTerminalDisconnected(): void
+ {
+ $this->isTerminalConnected = false;
+ }
+
+ public function keepTerminalPageAlive(): void
+ {
+ $this->isTerminalConnected = true;
+ }
+
public function render()
{
return view('livewire.project.shared.terminal');
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index a263acedf..c275ec097 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Laravel\Sanctum\PersonalAccessToken;
+use Livewire\Attributes\Locked;
use Livewire\Component;
class ApiTokens extends Component
@@ -13,14 +14,26 @@ class ApiTokens extends Component
public ?string $description = null;
+ public ?int $expiresInDays = 30;
+
public $tokens = [];
public array $permissions = ['read'];
+ public array $expirationOptions = [
+ 7 => '7 days',
+ 30 => '30 days',
+ 60 => '60 days',
+ 90 => '90 days',
+ 365 => '1 year',
+ ];
+
public $isApiEnabled;
+ #[Locked]
public bool $canUseRootPermissions = false;
+ #[Locked]
public bool $canUseWritePermissions = false;
public function render()
@@ -44,7 +57,7 @@ class ApiTokens extends Component
public function updatedPermissions($permissionToUpdate)
{
// Check if user is trying to use restricted permissions
- if ($permissionToUpdate == 'root' && ! $this->canUseRootPermissions) {
+ if ($permissionToUpdate == 'root' && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use root permissions.');
// Remove root from permissions if it was somehow added
$this->permissions = array_diff($this->permissions, ['root']);
@@ -52,7 +65,7 @@ class ApiTokens extends Component
return;
}
- if (in_array($permissionToUpdate, ['write', 'write:sensitive']) && ! $this->canUseWritePermissions) {
+ if (in_array($permissionToUpdate, ['write', 'write:sensitive'], true) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
$this->dispatch('error', 'You do not have permission to use write permissions.');
// Remove write permissions if they were somehow added
$this->permissions = array_diff($this->permissions, ['write', 'write:sensitive']);
@@ -62,7 +75,7 @@ class ApiTokens extends Component
if ($permissionToUpdate == 'root') {
$this->permissions = ['root'];
- } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions)) {
+ } elseif ($permissionToUpdate == 'read:sensitive' && ! in_array('read', $this->permissions, true)) {
$this->permissions[] = 'read';
} elseif ($permissionToUpdate == 'deploy') {
$this->permissions = ['deploy'];
@@ -80,18 +93,20 @@ class ApiTokens extends Component
$this->authorize('create', PersonalAccessToken::class);
// Validate permissions based on user role
- if (in_array('root', $this->permissions) && ! $this->canUseRootPermissions) {
+ if (in_array('root', $this->permissions, true) && ! auth()->user()->can('useRootPermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with root permissions.');
}
- if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! $this->canUseWritePermissions) {
+ if (array_intersect(['write', 'write:sensitive'], $this->permissions) && ! auth()->user()->can('useWritePermissions', PersonalAccessToken::class)) {
throw new \Exception('You do not have permission to create tokens with write permissions.');
}
$this->validate([
'description' => 'required|min:3|max:255',
+ 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
- $token = auth()->user()->createToken($this->description, array_values($this->permissions));
+ $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
+ $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$this->getTokens();
session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {
diff --git a/app/Livewire/Server/Charts.php b/app/Livewire/Server/Charts.php
index d0db87f57..1cda771a7 100644
--- a/app/Livewire/Server/Charts.php
+++ b/app/Livewire/Server/Charts.php
@@ -2,11 +2,15 @@
namespace App\Livewire\Server;
+use App\Actions\Server\StartSentinel;
use App\Models\Server;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Charts extends Component
{
+ use AuthorizesRequests;
+
public Server $server;
public $chartId = 'server';
@@ -28,6 +32,29 @@ class Charts extends Component
}
}
+ public function toggleMetrics(): void
+ {
+ try {
+ $this->authorize('update', $this->server);
+ $this->server->settings->is_metrics_enabled = ! $this->server->settings->is_metrics_enabled;
+ $this->server->settings->save();
+ $this->server->refresh();
+
+ if ($this->server->isMetricsEnabled()) {
+ StartSentinel::run($this->server, true);
+ $this->dispatch('success', 'Metrics enabled. Starting Sentinel.');
+ $this->dispatch('refreshServerShow');
+ $this->redirect(route('server.metrics', ['server_uuid' => $this->server->uuid]), navigate: true);
+ } else {
+ $this->server->restartSentinel();
+ $this->dispatch('success', 'Metrics disabled. Restarting Sentinel.');
+ $this->dispatch('refreshServerShow');
+ }
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
public function pollData()
{
if ($this->poll || $this->interval <= 10) {
diff --git a/app/Livewire/Server/Destinations.php b/app/Livewire/Server/Destinations.php
index 117b43ad6..f3f142646 100644
--- a/app/Livewire/Server/Destinations.php
+++ b/app/Livewire/Server/Destinations.php
@@ -45,7 +45,7 @@ class Destinations extends Component
} else {
SwarmDocker::create([
'name' => $this->server->name.'-'.$name,
- 'network' => $this->name,
+ 'network' => $name,
'server_id' => $this->server->id,
]);
}
diff --git a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
index c67591cf5..20d14ddc7 100644
--- a/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
+++ b/app/Livewire/Server/Proxy/DynamicConfigurationNavbar.php
@@ -28,12 +28,11 @@ class DynamicConfigurationNavbar extends Component
// Decode filename: pipes are used to encode dots for Livewire property binding
// (e.g., 'my|service.yaml' -> 'my.service.yaml')
- // This must happen BEFORE validation because validateShellSafePath() correctly
- // rejects pipe characters as dangerous shell metacharacters
+ // This must happen BEFORE validation because validateFilenameSafe()
+ // rejects pipe characters through validateShellSafePath().
$file = str_replace('|', '.', $fileName);
- // Validate filename to prevent command injection
- validateShellSafePath($file, 'proxy configuration filename');
+ validateFilenameSafe($file, 'proxy configuration filename');
if ($proxy_type === 'CADDY' && $file === 'Caddyfile') {
$this->dispatch('error', 'Cannot delete Caddyfile.');
diff --git a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
index 31a1dfc7e..481d89c78 100644
--- a/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
+++ b/app/Livewire/Server/Proxy/NewDynamicConfiguration.php
@@ -43,8 +43,7 @@ class NewDynamicConfiguration extends Component
'value' => 'required',
]);
- // Additional security validation to prevent command injection
- validateShellSafePath($this->fileName, 'proxy configuration filename');
+ validateFilenameSafe($this->fileName, 'proxy configuration filename');
if (data_get($this->parameters, 'server_uuid')) {
$this->server = Server::ownedByCurrentTeam()->whereUuid(data_get($this->parameters, 'server_uuid'))->first();
diff --git a/app/Livewire/Server/Sentinel.php b/app/Livewire/Server/Sentinel.php
index a4b35891b..909ed54f9 100644
--- a/app/Livewire/Server/Sentinel.php
+++ b/app/Livewire/Server/Sentinel.php
@@ -15,8 +15,6 @@ class Sentinel extends Component
public Server $server;
- public array $parameters = [];
-
public bool $isMetricsEnabled;
#[Validate(['required', 'string', 'max:500', 'regex:/\A[a-zA-Z0-9._\-+=\/]+\z/'])]
@@ -51,15 +49,9 @@ class Sentinel extends Component
];
}
- public function mount(string $server_uuid)
+ public function mount()
{
- try {
- $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
- $this->parameters = get_route_parameters();
- $this->syncData();
- } catch (\Throwable) {
- return redirect()->route('server.index');
- }
+ $this->syncData();
}
public function syncData(bool $toModel = false)
@@ -93,7 +85,9 @@ class Sentinel extends Component
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
- $this->syncData();
+ // Only refresh display-only state; never re-sync text-input properties
+ // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
+ $this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
@@ -110,27 +104,29 @@ class Sentinel extends Component
}
}
- public function updatedIsSentinelEnabled($value)
+ public function toggleSentinel(): void
{
try {
$this->authorize('manageSentinel', $this->server);
- if ($value === true) {
+ if (! $this->isSentinelEnabled) {
if ($this->server->isBuildServer()) {
- $this->isSentinelEnabled = false;
$this->dispatch('error', 'Sentinel cannot be enabled on build servers.');
return;
}
+ $this->isSentinelEnabled = true;
$customImage = isDev() ? $this->sentinelCustomDockerImage : null;
StartSentinel::run($this->server, true, null, $customImage);
} else {
+ $this->isSentinelEnabled = false;
$this->isMetricsEnabled = false;
$this->isSentinelDebugEnabled = false;
StopSentinel::dispatch($this->server);
}
$this->submit();
+ $this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
- return handleError($e, $this);
+ handleError($e, $this);
}
}
diff --git a/app/Livewire/Server/Sentinel/Logs.php b/app/Livewire/Server/Sentinel/Logs.php
new file mode 100644
index 000000000..6619e101e
--- /dev/null
+++ b/app/Livewire/Server/Sentinel/Logs.php
@@ -0,0 +1,29 @@
+parameters = get_route_parameters();
+ try {
+ $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.server.sentinel.logs');
+ }
+}
diff --git a/app/Livewire/Server/Sentinel/Show.php b/app/Livewire/Server/Sentinel/Show.php
new file mode 100644
index 000000000..7070a09ce
--- /dev/null
+++ b/app/Livewire/Server/Sentinel/Show.php
@@ -0,0 +1,29 @@
+parameters = get_route_parameters();
+ try {
+ $this->server = Server::ownedByCurrentTeam()->whereUuid(request()->server_uuid)->firstOrFail();
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.server.sentinel.show');
+ }
+}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 84cb65ee6..d7339dcdb 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -32,6 +32,8 @@ class Show extends Component
public string $port;
+ public int $connectionTimeout;
+
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@@ -110,6 +112,7 @@ class Show extends Component
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
+ 'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@@ -138,6 +141,10 @@ class Show extends Component
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
+ 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
+ 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+ 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
+ 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@@ -210,6 +217,7 @@ class Show extends Component
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
+ $this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@@ -237,6 +245,7 @@ class Show extends Component
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
+ $this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@@ -268,7 +277,9 @@ class Show extends Component
// Only refresh if the event is for this server
if (isset($event['serverUuid']) && $event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
- $this->syncData();
+ // Only refresh display-only state; never re-sync text-input properties
+ // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
+ $this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
@@ -407,7 +418,7 @@ class Show extends Component
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@@ -448,12 +459,15 @@ class Show extends Component
return;
}
- // Refresh server data
+ // Refresh server data and only the display-only state that validation produces.
+ // Never re-sync text-input properties via syncData() — would clobber any
+ // unsaved typing (see coolify#6062 / #6354 / #9695).
$this->server->refresh();
- $this->syncData();
-
- // Update validation state
+ $this->server->settings->refresh();
$this->isValidating = $this->server->is_validating ?? false;
+ $this->validationLogs = $this->server->validation_logs;
+ $this->isReachable = $this->server->settings->is_reachable;
+ $this->isUsable = $this->server->settings->is_usable;
// Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens();
@@ -471,7 +485,7 @@ class Show extends Component
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index d31f68859..3a6237183 100644
--- a/app/Livewire/Settings/Advanced.php
+++ b/app/Livewire/Settings/Advanced.php
@@ -37,6 +37,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 [
@@ -49,6 +52,7 @@ class Advanced extends Component
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
}
@@ -67,6 +71,7 @@ class Advanced extends Component
$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()
@@ -150,6 +155,7 @@ class Advanced extends Component
$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 9a51d107d..c2789aa91 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -35,7 +35,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 = [];
@@ -49,6 +49,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()
@@ -184,6 +185,8 @@ class Index extends Component
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.');
@@ -191,7 +194,14 @@ class Index extends Component
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/SettingsDropdown.php b/app/Livewire/SettingsDropdown.php
index 7afa763df..cd41197cb 100644
--- a/app/Livewire/SettingsDropdown.php
+++ b/app/Livewire/SettingsDropdown.php
@@ -11,6 +11,8 @@ class SettingsDropdown extends Component
{
public $showWhatsNewModal = false;
+ public string $trigger = 'preferences';
+
public function getUnreadCountProperty()
{
return Auth::user()->getUnreadChangelogCount();
diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index d6537069c..648bfe6ee 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -7,7 +7,9 @@ use App\Models\GithubApp;
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
+use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
@@ -19,6 +21,10 @@ class Change extends Component
public string $webhook_endpoint = '';
+ public string $custom_webhook_endpoint = '';
+
+ public bool $use_custom_webhook_endpoint = false;
+
public ?string $ipv4 = null;
public ?string $ipv6 = null;
@@ -72,6 +78,10 @@ class Change extends Component
public $privateKeys;
+ public string $manifestState = '';
+
+ public string $activeTab = 'general';
+
protected function rules(): array
{
return [
@@ -91,6 +101,9 @@ class Change extends Component
'metadata' => 'nullable|string',
'pullRequests' => 'nullable|string',
'privateKeyId' => 'nullable|int',
+ 'webhook_endpoint' => ['required', 'string', 'url'],
+ 'custom_webhook_endpoint' => ['nullable', 'string', 'url'],
+ 'use_custom_webhook_endpoint' => ['required', 'bool'],
];
}
@@ -147,6 +160,24 @@ class Change extends Component
}
}
+ private function githubAppSetupStateCacheKey(string $state): string
+ {
+ return 'github-app-setup-state:'.hash('sha256', $state);
+ }
+
+ private function createGithubAppSetupState(string $action): string
+ {
+ $state = Str::random(64);
+
+ Cache::put($this->githubAppSetupStateCacheKey($state), [
+ 'action' => $action,
+ 'github_app_id' => $this->github_app->id,
+ 'team_id' => $this->github_app->team_id,
+ ], now()->addMinutes(60));
+
+ return $state;
+ }
+
public function checkPermissions()
{
try {
@@ -179,6 +210,9 @@ class Change extends Component
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
+ $this->syncData(false);
+ $this->name = str($this->github_app->name)->kebab();
+
$this->dispatch('success', 'Github App permissions updated.');
} catch (\Throwable $e) {
// Provide better error message for unsupported key formats
@@ -211,6 +245,7 @@ class Change extends Component
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
$this->fqdn = $settings->fqdn;
+ $this->manifestState = $this->createGithubAppSetupState('manifest');
if ($settings->public_ipv4) {
$this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port');
@@ -240,10 +275,18 @@ class Change extends Component
}
}
$this->parameters = get_route_parameters();
+ $routeName = request()->route()?->getName();
+ if ($routeName === 'source.github.permissions') {
+ $this->activeTab = 'permissions';
+ } elseif ($routeName === 'source.github.resources') {
+ $this->activeTab = 'resources';
+ } else {
+ $this->activeTab = 'general';
+ }
if (isCloud() && ! isDev()) {
$this->webhook_endpoint = config('app.url');
} else {
- $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? '';
+ $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? '';
$this->is_system_wide = $this->github_app->is_system_wide;
}
} catch (\Throwable $e) {
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 @@ class Create extends Component
'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 @@ class Create extends Component
'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 791226334..342d629cb 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;
@@ -42,7 +43,7 @@ class Form extends Component
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
- 'endpoint' => 'required|url|max:255',
+ 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -60,7 +61,6 @@ class Form extends Component
'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/Resources.php b/app/Livewire/Storage/Resources.php
index 643ecb3eb..0dad2d548 100644
--- a/app/Livewire/Storage/Resources.php
+++ b/app/Livewire/Storage/Resources.php
@@ -25,7 +25,9 @@ class Resources extends Component
public function disableS3(int $backupId): void
{
- $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+ $backup = ScheduledDatabaseBackup::where('id', $backupId)
+ ->where('s3_storage_id', $this->storage->id)
+ ->firstOrFail();
$backup->update([
'save_s3' => false,
@@ -39,7 +41,9 @@ class Resources extends Component
public function moveBackup(int $backupId): void
{
- $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+ $backup = ScheduledDatabaseBackup::where('id', $backupId)
+ ->where('s3_storage_id', $this->storage->id)
+ ->firstOrFail();
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
diff --git a/app/Livewire/Team/InviteLink.php b/app/Livewire/Team/InviteLink.php
index ee6d535e9..fb30961e9 100644
--- a/app/Livewire/Team/InviteLink.php
+++ b/app/Livewire/Team/InviteLink.php
@@ -61,7 +61,7 @@ class InviteLink extends Component
if ($member_emails->contains($this->email)) {
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
}
- $uuid = new Cuid2(32);
+ $uuid = (string) new Cuid2(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid;
$user = User::whereEmail($this->email)->first();
@@ -73,7 +73,7 @@ class InviteLink extends Component
'password' => Hash::make($password),
'force_password_reset' => true,
]);
- $token = Crypt::encryptString("{$user->email}@@@$password");
+ $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]);
}
$invitation = TeamInvitation::whereEmail($this->email)->first();
diff --git a/app/Livewire/Team/Member.php b/app/Livewire/Team/Member.php
index b1f692365..97d492d70 100644
--- a/app/Livewire/Team/Member.php
+++ b/app/Livewire/Team/Member.php
@@ -2,6 +2,7 @@
namespace App\Livewire\Team;
+use App\Actions\User\RevokeUserTeamTokens;
use App\Enums\Role;
use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -23,7 +24,9 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
- $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::ADMIN->value]);
+ $teamId = currentTeam()->id;
+ $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
+ RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -39,7 +42,9 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
- $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::OWNER->value]);
+ $teamId = currentTeam()->id;
+ $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
+ RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -55,7 +60,9 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
- $this->member->teams()->updateExistingPivot(currentTeam()->id, ['role' => Role::MEMBER->value]);
+ $teamId = currentTeam()->id;
+ $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
+ RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -73,6 +80,7 @@ class Member extends Component
}
$teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam());
+ RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
// Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}");
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..f6d82453a
--- /dev/null
+++ b/app/Mcp/Concerns/ResolvesTeam.php
@@ -0,0 +1,41 @@
+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
+ {
+ $user = $request->user();
+ $token = $user?->currentAccessToken();
+ $teamId = $token?->team_id;
+
+ if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
+ return null;
+ }
+
+ return (int) $teamId;
+ }
+}
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 fef6f6e4c..1ffa62584 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -4,6 +4,9 @@ namespace App\Models;
use App\Enums\ApplicationDeploymentStatus;
use App\Services\ConfigurationGenerator;
+use App\Services\DeploymentConfiguration\ApplicationConfigurationSnapshot;
+use App\Services\DeploymentConfiguration\ConfigurationDiff;
+use App\Services\DeploymentConfiguration\ConfigurationDiffer;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasConfiguration;
use App\Traits\HasMetrics;
@@ -39,7 +42,7 @@ use Visus\Cuid2\Cuid2;
'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.'],
@@ -201,6 +204,7 @@ class Application extends BaseModel
'config_hash',
'last_online_at',
'restart_count',
+ 'max_restart_count',
'last_restart_at',
'last_restart_type',
'uuid',
@@ -215,14 +219,28 @@ class Application extends BaseModel
protected $appends = ['server_status'];
- protected $casts = [
- 'http_basic_auth_password' => 'encrypted',
- 'restart_count' => 'integer',
- 'last_restart_at' => 'datetime',
- ];
+ protected function casts(): array
+ {
+ return [
+ 'http_basic_auth_password' => 'encrypted',
+ 'manual_webhook_secret_github' => 'encrypted',
+ 'manual_webhook_secret_gitlab' => 'encrypted',
+ 'manual_webhook_secret_bitbucket' => 'encrypted',
+ 'manual_webhook_secret_gitea' => 'encrypted',
+ 'restart_count' => 'integer',
+ 'max_restart_count' => 'integer',
+ 'last_restart_at' => 'datetime',
+ ];
+ }
protected static function booted()
{
+ static::creating(function ($application) {
+ $application->manual_webhook_secret_github ??= Str::random(40);
+ $application->manual_webhook_secret_gitlab ??= Str::random(40);
+ $application->manual_webhook_secret_bitbucket ??= Str::random(40);
+ $application->manual_webhook_secret_gitea ??= Str::random(40);
+ });
static::addGlobalScope('withRelations', function ($builder) {
$builder->withCount([
'additional_servers',
@@ -554,6 +572,15 @@ class Application extends BaseModel
return null;
}
+ public function stoppedAfterRestartLimit(): bool
+ {
+ return str($this->status)->startsWith('exited')
+ && ($this->restart_count ?? 0) > 0
+ && ($this->max_restart_count ?? 0) > 0
+ && $this->restart_count >= $this->max_restart_count
+ && $this->last_restart_type === 'crash';
+ }
+
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
@@ -707,14 +734,14 @@ class Application extends BaseModel
return Attribute::make(
set: function ($value) {
if (is_null($value) || $value === '') {
- return '/Dockerfile';
- } else {
- if ($value !== '/') {
- return Str::start(Str::replaceEnd('/', '', $value), '/');
- }
-
- return Str::start($value, '/');
+ return $this->build_pack === 'dockerfile' ? '/Dockerfile' : null;
}
+
+ if ($value !== '/') {
+ return Str::start(Str::replaceEnd('/', '', $value), '/');
+ }
+
+ return Str::start($value, '/');
}
);
}
@@ -873,8 +900,8 @@ class Application extends BaseModel
public function customNginxConfiguration(): Attribute
{
return Attribute::make(
- set: fn ($value) => base64_encode($value),
- get: fn ($value) => base64_decode($value),
+ set: fn ($value) => is_null($value) ? null : base64_encode($value),
+ get: fn ($value) => is_null($value) ? null : base64_decode($value),
);
}
@@ -947,7 +974,7 @@ class Application extends BaseModel
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@@ -957,6 +984,13 @@ class Application extends BaseModel
->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')
@@ -975,7 +1009,7 @@ class Application extends BaseModel
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@@ -985,6 +1019,13 @@ class Application extends BaseModel
->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');
@@ -1032,7 +1073,7 @@ class Application extends BaseModel
public function get_last_successful_deployment()
{
- return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
+ return ApplicationDeploymentQueue::where('application_id', $this->id)->where('status', ApplicationDeploymentStatus::FINISHED->value)->where('pull_request_id', 0)->orderBy('created_at', 'desc')->first();
}
public function get_last_days_deployments()
@@ -1104,7 +1145,7 @@ class Application extends BaseModel
public function could_set_build_commands(): bool
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}
@@ -1143,33 +1184,95 @@ class Application extends BaseModel
}
public function isConfigurationChanged(bool $save = false)
+ {
+ $configurationDiff = $this->pendingDeploymentConfigurationDiff();
+
+ if ($save) {
+ $this->markDeploymentConfigurationApplied();
+ }
+
+ return $configurationDiff->isChanged();
+ }
+
+ public function pendingDeploymentConfigurationDiff(): ConfigurationDiff
+ {
+ $currentSnapshot = $this->deploymentConfigurationSnapshot();
+ $lastDeployment = $this->get_last_successful_deployment();
+
+ $previousSnapshot = $lastDeployment?->configuration_snapshot;
+
+ if (! $previousSnapshot) {
+ $oldConfigHash = data_get($this, 'config_hash');
+ $hasLegacyChange = $oldConfigHash === null || $oldConfigHash !== $this->legacyConfigurationHash();
+
+ if (! $hasLegacyChange) {
+ return ConfigurationDiff::unchanged();
+ }
+
+ $previousSnapshot = [];
+ }
+
+ return app(ConfigurationDiffer::class)->diff($previousSnapshot, $currentSnapshot);
+ }
+
+ public function hasPendingDeploymentConfigurationChanges(): bool
+ {
+ return $this->pendingDeploymentConfigurationDiff()->isChanged();
+ }
+
+ public function deploymentConfigurationSnapshot(): array
+ {
+ return (new ApplicationConfigurationSnapshot($this))->toArray();
+ }
+
+ public function deploymentConfigurationHash(): string
+ {
+ return ApplicationConfigurationSnapshot::hashSnapshot($this->deploymentConfigurationSnapshot());
+ }
+
+ public function markDeploymentConfigurationApplied(?ApplicationDeploymentQueue $deployment = null): void
+ {
+ $this->refresh();
+
+ if (! $deployment) {
+ $this->forceFill(['config_hash' => $this->legacyConfigurationHash()])->save();
+
+ return;
+ }
+
+ $snapshot = $this->deploymentConfigurationSnapshot();
+ $hash = ApplicationConfigurationSnapshot::hashSnapshot($snapshot);
+
+ $previousDeployment = ApplicationDeploymentQueue::query()
+ ->where('application_id', $this->id)
+ ->where('status', ApplicationDeploymentStatus::FINISHED->value)
+ ->where('pull_request_id', $deployment->pull_request_id ?? 0)
+ ->where('id', '!=', $deployment->id)
+ ->whereNotNull('configuration_snapshot')
+ ->latest()
+ ->first();
+
+ $deployment->update([
+ 'configuration_hash' => $hash,
+ 'configuration_snapshot' => $snapshot,
+ 'configuration_diff' => $previousDeployment?->configuration_snapshot
+ ? app(ConfigurationDiffer::class)->diff($previousDeployment->configuration_snapshot, $snapshot)->toArray()
+ : null,
+ ]);
+
+ $this->forceFill(['config_hash' => $hash])->save();
+ }
+
+ private function legacyConfigurationHash(): string
{
$newConfigHash = base64_encode($this->fqdn.$this->git_repository.$this->git_branch.$this->git_commit_sha.$this->build_pack.$this->static_image.$this->install_command.$this->build_command.$this->start_command.$this->ports_exposes.$this->ports_mappings.$this->custom_network_aliases.$this->base_directory.$this->publish_directory.$this->dockerfile.$this->dockerfile_location.$this->custom_labels.$this->custom_docker_run_options.$this->dockerfile_target_build.$this->redirect.$this->custom_nginx_configuration.$this->settings?->use_build_secrets.$this->settings?->inject_build_args_to_dockerfile.$this->settings?->include_source_commit_in_build);
if ($this->pull_request_id === 0 || $this->pull_request_id === null) {
$newConfigHash .= json_encode($this->environment_variables()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
} else {
- $newConfigHash .= json_encode($this->environment_variables_preview->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
+ $newConfigHash .= json_encode($this->environment_variables_preview()->get(['value', 'is_multiline', 'is_literal', 'is_buildtime', 'is_runtime'])->sort());
}
- $newConfigHash = md5($newConfigHash);
- $oldConfigHash = data_get($this, 'config_hash');
- if ($oldConfigHash === null) {
- if ($save) {
- $this->config_hash = $newConfigHash;
- $this->save();
- }
- return true;
- }
- if ($oldConfigHash === $newConfigHash) {
- return false;
- } else {
- if ($save) {
- $this->config_hash = $newConfigHash;
- $this->save();
- }
-
- return true;
- }
+ return md5($newConfigHash);
}
public function customRepository()
@@ -1187,15 +1290,19 @@ class Application extends BaseModel
return application_configuration_dir()."/{$this->uuid}";
}
- public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $git_ssh_command = null)
+ public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false, ?string $commit = null, ?string $gitSshCommand = null, ?string $git_ssh_command = null, ?string $gitConfigOptions = null)
{
$baseDir = $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
+ $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
- // Use the full GIT_SSH_COMMAND (including -i for SSH key and port options) when provided,
- // so that git fetch, submodule update, and lfs pull can authenticate the same way as git clone.
- $sshCommand = $git_ssh_command ?? 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
+ $resolvedGitSshCommand = $git_ssh_command ?? $gitSshCommand;
+ $sshCommand = $resolvedGitSshCommand
+ ? (str_starts_with($resolvedGitSshCommand, 'GIT_SSH_COMMAND=')
+ ? $resolvedGitSshCommand
+ : 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
+ : 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
@@ -1206,9 +1313,9 @@ class Application extends BaseModel
// If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1
if ($isShallowCloneEnabled) {
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git fetch --depth=1 origin {$escapedCommit} && git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
} else {
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
}
}
if ($this->settings->is_git_submodules_enabled) {
@@ -1219,10 +1326,10 @@ class Application extends BaseModel
}
// Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
- $git_clone_command = "{$git_clone_command} git submodule sync && {$sshCommand} git submodule update --init --recursive {$submoduleFlags}; fi";
+ $git_clone_command = "{$git_clone_command} {$gitCommand} submodule sync && {$sshCommand} {$gitCommand} submodule update --init --recursive {$submoduleFlags}; fi";
}
if ($this->settings->is_git_lfs_enabled) {
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} git lfs pull";
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} lfs pull";
}
return $git_clone_command;
@@ -1463,6 +1570,11 @@ class Application extends BaseModel
} else {
$github_access_token = generateGithubInstallationToken($this->source);
$encodedToken = rawurlencode($github_access_token);
+
+ // Rewrite same-host HTTPS URLs only for these git commands so submodules can authenticate without persisting credentials.
+ $gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://x-access-token:{$encodedToken}@{$source_html_url_host}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}/");
+ $git_clone_command = str_replace('git clone', "git {$gitConfigOption} clone", $git_clone_command);
+
if ($exec_in_docker) {
$repoUrl = "$source_html_url_scheme://x-access-token:$encodedToken@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
@@ -1475,7 +1587,7 @@ class Application extends BaseModel
$fullRepoUrl = $repoUrl;
}
if (! $only_checkout) {
- $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit);
+ $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOption);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
@@ -1486,7 +1598,7 @@ class Application extends BaseModel
if ($pull_request_id !== 0) {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
- $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name);
+ $git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
$escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
@@ -1511,12 +1623,13 @@ class Application extends BaseModel
$private_key = base64_encode($private_key);
$gitlabPort = $gitlabSource->custom_port ?? 22;
$escapedCustomRepository = escapeshellarg($customRepository);
- $gitlabSshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
- $git_clone_command_base = "{$gitlabSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
+ $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
+ $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\"";
+ $git_clone_command_base = "{$gitlabGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
- $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $gitlabSshCommand);
+ $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
}
if ($exec_in_docker) {
$commands = collect([
@@ -1539,7 +1652,7 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
}
if ($exec_in_docker) {
@@ -1582,12 +1695,13 @@ class Application extends BaseModel
}
$private_key = base64_encode($private_key);
$escapedCustomRepository = escapeshellarg($customRepository);
- $deployKeySshCommand = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\"";
- $git_clone_command_base = "{$deployKeySshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
+ $deployKeySshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
+ $deployKeyGitSshCommand = "GIT_SSH_COMMAND=\"{$deployKeySshCommand}\"";
+ $git_clone_command_base = "{$deployKeyGitSshCommand} {$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
if ($only_checkout) {
$git_clone_command = $git_clone_command_base;
} else {
- $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, git_ssh_command: $deployKeySshCommand);
+ $git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
}
if ($exec_in_docker) {
$commands = collect([
@@ -1610,7 +1724,7 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1618,14 +1732,14 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$deployKeySshCommand}\" ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
}
}
@@ -1646,6 +1760,7 @@ class Application extends BaseModel
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit);
+ $otherSshCommand = "ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa";
if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') {
@@ -1655,7 +1770,7 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@@ -1663,14 +1778,14 @@ class Application extends BaseModel
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
- $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
+ $git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand);
}
}
@@ -1919,13 +2034,15 @@ class Application extends BaseModel
);
}
- protected function buildGitCheckoutCommand($target): string
+ protected function buildGitCheckoutCommand($target, ?string $gitSshCommand = null, ?string $gitConfigOptions = null): string
{
$escapedTarget = escapeshellarg($target);
- $command = "git checkout {$escapedTarget}";
+ $gitCommand = $gitConfigOptions ? "git {$gitConfigOptions}" : 'git';
+ $command = "{$gitCommand} checkout {$escapedTarget}";
if ($this->settings->is_git_submodules_enabled) {
- $command .= ' && git submodule update --init --recursive';
+ $sshCommand = $gitSshCommand ?? 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null';
+ $command .= " && GIT_SSH_COMMAND=\"{$sshCommand}\" {$gitCommand} submodule update --init --recursive";
}
return $command;
@@ -2240,7 +2357,7 @@ class Application extends BaseModel
'config.build_pack' => 'required|string',
'config.base_directory' => 'required|string',
'config.publish_directory' => 'required|string',
- 'config.ports_exposes' => 'required|string',
+ 'config.ports_exposes' => 'nullable|string',
'config.settings.is_static' => 'required|boolean',
]);
if ($deepValidator->fails()) {
diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php
index 67f28523c..53fb8337f 100644
--- a/app/Models/ApplicationDeploymentQueue.php
+++ b/app/Models/ApplicationDeploymentQueue.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Casts\EncryptedArrayCast;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
@@ -17,6 +18,9 @@ use OpenApi\Attributes as OA;
'deployment_uuid' => ['type' => 'string'],
'pull_request_id' => ['type' => 'integer'],
'docker_registry_image_tag' => ['type' => 'string', 'nullable' => true],
+ 'configuration_hash' => ['type' => 'string', 'nullable' => true],
+ 'configuration_snapshot' => ['type' => 'object', 'nullable' => true],
+ 'configuration_diff' => ['type' => 'object', 'nullable' => true],
'force_rebuild' => ['type' => 'boolean'],
'commit' => ['type' => 'string'],
'status' => ['type' => 'string'],
@@ -45,6 +49,9 @@ class ApplicationDeploymentQueue extends Model
'deployment_uuid',
'pull_request_id',
'docker_registry_image_tag',
+ 'configuration_hash',
+ 'configuration_snapshot',
+ 'configuration_diff',
'force_rebuild',
'commit',
'status',
@@ -68,9 +75,24 @@ class ApplicationDeploymentQueue extends Model
'finished_at',
];
+ /**
+ * The configuration snapshot/diff hold full (decrypted on read) configuration,
+ * including unlocked environment variable values. They are only meant for the
+ * in-app diff modal (which redacts per role) and must never be serialized by the
+ * API, so hide them globally as defense in depth.
+ *
+ * @var array
+ */
+ protected $hidden = [
+ 'configuration_snapshot',
+ 'configuration_diff',
+ ];
+
protected $casts = [
'pull_request_id' => 'integer',
'finished_at' => 'datetime',
+ 'configuration_snapshot' => EncryptedArrayCast::class,
+ 'configuration_diff' => EncryptedArrayCast::class,
];
public function application()
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 @@ class ApplicationPreview extends BaseModel
$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/ApplicationSetting.php b/app/Models/ApplicationSetting.php
index 731a9b5da..ef09c0c48 100644
--- a/app/Models/ApplicationSetting.php
+++ b/app/Models/ApplicationSetting.php
@@ -26,6 +26,7 @@ class ApplicationSetting extends Model
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'docker_images_to_keep' => 'integer',
+ 'stop_grace_period' => 'integer',
];
protected $fillable = [
@@ -64,8 +65,30 @@ class ApplicationSetting extends Model
'inject_build_args_to_dockerfile',
'include_source_commit_in_build',
'docker_images_to_keep',
+ 'stop_grace_period',
];
+ public function stopGracePeriodSeconds(): int
+ {
+ if (
+ $this->stop_grace_period >= MIN_STOP_GRACE_PERIOD_SECONDS &&
+ $this->stop_grace_period <= MAX_STOP_GRACE_PERIOD_SECONDS
+ ) {
+ return $this->stop_grace_period;
+ }
+
+ return DEFAULT_STOP_GRACE_PERIOD_SECONDS;
+ }
+
+ public function deploymentStopGracePeriodSeconds(): int
+ {
+ if (isDev() && $this->stop_grace_period === null) {
+ return MIN_STOP_GRACE_PERIOD_SECONDS;
+ }
+
+ return $this->stopGracePeriodSeconds();
+ }
+
public function isStatic(): Attribute
{
return Attribute::make(
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 @@ use OpenApi\Attributes as OA;
)]
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 @@ class EnvironmentVariable extends BaseModel
]);
});
- static::saving(function (EnvironmentVariable $environmentVariable) {
+ static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@@ -119,6 +123,30 @@ class EnvironmentVariable extends BaseModel
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 @@ class EnvironmentVariable extends BaseModel
);
}
- 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 @@ class EnvironmentVariable extends BaseModel
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/GithubApp.php b/app/Models/GithubApp.php
index 54bbb3f7d..e5032d2d0 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -73,26 +73,6 @@ class GithubApp extends BaseModel
});
}
- public static function public()
- {
- return GithubApp::where(function ($query) {
- $query->where(function ($q) {
- $q->where('team_id', currentTeam()->id)
- ->orWhere('is_system_wide', true);
- })->where('is_public', true);
- })->whereNotNull('app_id')->get();
- }
-
- public static function private()
- {
- return GithubApp::where(function ($query) {
- $query->where(function ($q) {
- $q->where('team_id', currentTeam()->id)
- ->orWhere('is_system_wide', true);
- })->where('is_public', false);
- })->whereNotNull('app_id')->get();
- }
-
public function team()
{
return $this->belongsTo(Team::class);
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 @@ use Symfony\Component\Yaml\Yaml;
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 @@ class LocalFileVolume extends BaseModel
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 @@ class LocalFileVolume extends BaseModel
$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 @@ class LocalFileVolume extends BaseModel
}
}
+ 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 @@ class LocalFileVolume extends BaseModel
$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/PersonalAccessToken.php b/app/Models/PersonalAccessToken.php
index 398046a7c..503377bec 100644
--- a/app/Models/PersonalAccessToken.php
+++ b/app/Models/PersonalAccessToken.php
@@ -11,6 +11,14 @@ class PersonalAccessToken extends SanctumPersonalAccessToken
'token',
'abilities',
'expires_at',
+ 'api_token_expiration_warning_sent_at',
'team_id',
];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_token_expiration_warning_sent_at' => 'datetime',
+ ];
+ }
}
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index d6feccc7e..190ee6e67 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -2,17 +2,24 @@
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
{
use HasFactory, HasSafeStringAttribute;
+ private const CONNECTION_TIMEOUT_SECONDS = 15;
+
+ private const REQUEST_TIMEOUT_SECONDS = 15;
+
protected $fillable = [
+ 'team_id',
'name',
'description',
'region',
@@ -66,6 +73,13 @@ class S3Storage extends BaseModel
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
}
+ public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
+ {
+ $selectArray = collect($select)->concat(['id']);
+
+ return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
+ }
+
public function isUsable()
{
return $this->is_usable;
@@ -132,6 +146,14 @@ class S3Storage extends BaseModel
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'],
@@ -140,6 +162,10 @@ class S3Storage extends BaseModel
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
+ 'http' => [
+ 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
+ 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
+ ],
]);
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
@@ -147,11 +173,12 @@ class S3Storage extends BaseModel
$this->unusable_email_sent = false;
$this->is_usable = true;
} catch (\Throwable $e) {
+ $exception = $this->toUserFriendlyConnectionException($e);
$this->is_usable = false;
if ($this->unusable_email_sent === false && is_transactional_emails_enabled()) {
$mail = new MailMessage;
$mail->subject('Coolify: S3 Storage Connection Error');
- $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $e->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
+ $mail->view('emails.s3-connection-error', ['name' => $this->name, 'reason' => $exception->getMessage(), 'url' => route('storage.show', ['storage_uuid' => $this->uuid])]);
// Load the team with its members and their roles explicitly
$team = $this->team()->with(['members' => function ($query) {
@@ -166,11 +193,25 @@ class S3Storage extends BaseModel
$this->unusable_email_sent = true;
}
- throw $e;
+ throw $exception;
} finally {
if ($shouldSave) {
$this->save();
}
}
}
+
+ private function toUserFriendlyConnectionException(\Throwable $exception): \Throwable
+ {
+ $message = str($exception->getMessage())->lower();
+
+ if ($message->contains(['timed out', 'timeout', 'connection refused', 'could not resolve', 'curl error 28'])) {
+ return new \RuntimeException(
+ 'Could not connect to the S3 endpoint within 15 seconds. Please verify the endpoint, bucket, credentials, region, and network/firewall settings.',
+ previous: $exception,
+ );
+ }
+
+ return $exception;
+ }
}
diff --git a/app/Models/ScheduledDatabaseBackupExecution.php b/app/Models/ScheduledDatabaseBackupExecution.php
index 51ad46de9..1d5f5f9ce 100644
--- a/app/Models/ScheduledDatabaseBackupExecution.php
+++ b/app/Models/ScheduledDatabaseBackupExecution.php
@@ -23,6 +23,7 @@ class ScheduledDatabaseBackupExecution extends BaseModel
protected function casts(): array
{
return [
+ 'size' => 'integer',
's3_uploaded' => 'boolean',
'local_storage_deleted' => 'boolean',
's3_storage_deleted' => 'boolean',
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 @@ class ScheduledTask extends BaseModel
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 @@ $schema://$host {
$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 @@ $schema://$host {
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 @@ use OpenApi\Attributes as OA;
'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 @@ class ServerSetting extends Model
* 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/Service.php b/app/Models/Service.php
index 11189b4ac..cc8074b74 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -778,7 +778,8 @@ class Service extends BaseModel
}
$rpc_secret = $this->environment_variables()->where('key', 'GARAGE_RPC_SECRET')->first();
if (is_null($rpc_secret)) {
- $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
+ $rpc_secret = $this->environment_variables()->where('key', 'SERVICE_HEX_64_RPCSECRET')->first()
+ ?? $this->environment_variables()->where('key', 'SERVICE_HEX_32_RPCSECRET')->first();
}
$metrics_token = $this->environment_variables()->where('key', 'GARAGE_METRICS_TOKEN')->first();
if (is_null($metrics_token)) {
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/StandaloneClickhouse.php b/app/Models/StandaloneClickhouse.php
index 784e2c937..b104be642 100644
--- a/app/Models/StandaloneClickhouse.php
+++ b/app/Models/StandaloneClickhouse.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneClickhouse extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -44,11 +45,21 @@ class StandaloneClickhouse extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'clickhouse_admin_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@@ -111,6 +122,7 @@ class StandaloneClickhouse extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index dcb349405..1c5cfd342 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -5,6 +5,7 @@ namespace App\Models;
use App\Jobs\ConnectProxyToNetworksJob;
use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class StandaloneDocker extends BaseModel
@@ -90,6 +91,16 @@ class StandaloneDocker extends BaseModel
return $this->belongsTo(Server::class);
}
+ public static function ownedByCurrentTeam()
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
+ }
+
+ public static function ownedByCurrentTeamAPI(int $teamId)
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
+ }
+
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
@@ -117,15 +128,18 @@ class StandaloneDocker extends BaseModel
return $this->morphMany(Service::class, 'destination');
}
- public function databases()
+ public function databases(): Collection
{
$postgresqls = $this->postgresqls;
$redis = $this->redis;
$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/StandaloneDragonfly.php b/app/Models/StandaloneDragonfly.php
index e07053c03..2232ec772 100644
--- a/app/Models/StandaloneDragonfly.php
+++ b/app/Models/StandaloneDragonfly.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneDragonfly extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -43,11 +44,21 @@ class StandaloneDragonfly extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'dragonfly_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@@ -110,6 +121,7 @@ class StandaloneDragonfly extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneKeydb.php b/app/Models/StandaloneKeydb.php
index 979f45a3d..b9f9f765b 100644
--- a/app/Models/StandaloneKeydb.php
+++ b/app/Models/StandaloneKeydb.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneKeydb extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -44,11 +45,21 @@ class StandaloneKeydb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'keydb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@@ -111,6 +122,7 @@ class StandaloneKeydb extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->keydb_conf;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneMariadb.php b/app/Models/StandaloneMariadb.php
index dba8a52f5..cd94b6c9b 100644
--- a/app/Models/StandaloneMariadb.php
+++ b/app/Models/StandaloneMariadb.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -12,7 +13,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMariadb extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -47,11 +48,21 @@ class StandaloneMariadb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'mariadb_password' => 'encrypted',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
@@ -114,6 +125,7 @@ class StandaloneMariadb extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mariadb_conf;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneMongodb.php b/app/Models/StandaloneMongodb.php
index e72f4f1c6..7d2ffbd74 100644
--- a/app/Models/StandaloneMongodb.php
+++ b/app/Models/StandaloneMongodb.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMongodb extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -47,11 +48,21 @@ class StandaloneMongodb extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -120,6 +131,7 @@ class StandaloneMongodb extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mongo_conf;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneMysql.php b/app/Models/StandaloneMysql.php
index 1c522d200..f752312d3 100644
--- a/app/Models/StandaloneMysql.php
+++ b/app/Models/StandaloneMysql.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneMysql extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -48,11 +49,21 @@ class StandaloneMysql extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'mysql_password' => 'encrypted',
'mysql_root_password' => 'encrypted',
'public_port_timeout' => 'integer',
@@ -116,6 +127,7 @@ class StandaloneMysql extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->mysql_conf;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandalonePostgresql.php b/app/Models/StandalonePostgresql.php
index 57dfe5988..04d2291b3 100644
--- a/app/Models/StandalonePostgresql.php
+++ b/app/Models/StandalonePostgresql.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandalonePostgresql extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -50,11 +51,21 @@ class StandalonePostgresql extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'init_scripts' => 'array',
'postgres_password' => 'encrypted',
'public_port_timeout' => 'integer',
@@ -158,6 +169,7 @@ class StandalonePostgresql extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->postgres_initdb_args.$this->postgres_host_auth_method;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/StandaloneRedis.php b/app/Models/StandaloneRedis.php
index ef42d7f18..efb0254fb 100644
--- a/app/Models/StandaloneRedis.php
+++ b/app/Models/StandaloneRedis.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Traits\ClearsGlobalSearchCache;
+use App\Traits\HasDatabaseHealthCheck;
use App\Traits\HasMetrics;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -11,7 +12,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
class StandaloneRedis extends BaseModel
{
- use ClearsGlobalSearchCache, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
+ use ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
protected $fillable = [
'uuid',
@@ -43,11 +44,21 @@ class StandaloneRedis extends BaseModel
'destination_type',
'destination_id',
'environment_id',
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
protected $casts = [
+ 'health_check_enabled' => 'boolean',
+ 'health_check_interval' => 'integer',
+ 'health_check_timeout' => 'integer',
+ 'health_check_retries' => 'integer',
+ 'health_check_start_period' => 'integer',
'public_port_timeout' => 'integer',
'restart_count' => 'integer',
'last_restart_at' => 'datetime',
@@ -115,6 +126,7 @@ class StandaloneRedis extends BaseModel
public function isConfigurationChanged(bool $save = false)
{
$newConfigHash = $this->image.$this->ports_mappings.$this->redis_conf;
+ $newConfigHash .= $this->healthCheckConfigurationHash();
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php
index 134e36189..0e9620457 100644
--- a/app/Models/SwarmDocker.php
+++ b/app/Models/SwarmDocker.php
@@ -71,6 +71,16 @@ class SwarmDocker extends BaseModel
return $this->belongsTo(Server::class);
}
+ public static function ownedByCurrentTeam()
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
+ }
+
+ public static function ownedByCurrentTeamAPI(int $teamId)
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
+ }
+
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
diff --git a/app/Models/Team.php b/app/Models/Team.php
index 0fbcfe0c6..f0a50cf69 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Actions\User\RevokeUserTeamTokens;
use App\Events\ServerReachabilityChanged;
use App\Notifications\Channels\SendsDiscord;
use App\Notifications\Channels\SendsEmail;
@@ -72,6 +73,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
});
static::deleting(function (Team $team) {
+ RevokeUserTeamTokens::forTeam($team->id);
+
foreach ($team->privateKeys as $key) {
$key->delete();
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 3199d2024..9cbe88835 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Actions\User\RevokeUserTeamTokens;
use App\Jobs\UpdateStripeCustomerEmailJob;
use App\Notifications\Channels\SendsEmail;
use App\Notifications\TransactionalEmails\EmailChangeVerification;
@@ -98,13 +99,31 @@ class User extends Authenticatable implements SendsEmail
$team['id'] = 0;
$team['name'] = 'Root Team';
}
+ $new_team = $user->id === 0 ? Team::find(0) : null;
+
+ if ($new_team !== null) {
+ $new_team->forceFill($team);
+ $new_team->save();
+
+ if (! $user->teams()->whereKey($new_team->id)->exists()) {
+ $user->teams()->attach($new_team, ['role' => 'owner']);
+ } else {
+ $user->teams()->updateExistingPivot($new_team->id, ['role' => 'owner']);
+ }
+
+ return;
+ }
+
$new_team = (new Team)->forceFill($team);
$new_team->save();
+
$user->teams()->attach($new_team, ['role' => 'owner']);
});
static::deleting(function (User $user) {
\DB::transaction(function () use ($user) {
+ RevokeUserTeamTokens::forUser($user);
+
$teams = $user->teams;
foreach ($teams as $team) {
$user_alone_in_team = $team->members->count() === 1;
@@ -142,6 +161,7 @@ class User extends Authenticatable implements SendsEmail
if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save();
+ RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id);
$team->members()->detach($user->id);
} else {
static::finalizeTeamDeletion($user, $team);
@@ -257,7 +277,7 @@ class User extends Authenticatable implements SendsEmail
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/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php
new file mode 100644
index 000000000..635dfdbdc
--- /dev/null
+++ b/app/Notifications/Application/RestartLimitReached.php
@@ -0,0 +1,141 @@
+onQueue('high');
+ $this->afterCommit();
+ $this->resource_name = data_get($resource, 'name');
+ $this->project_uuid = data_get($resource, 'environment.project.uuid');
+ $this->environment_uuid = data_get($resource, 'environment.uuid');
+ $this->environment_name = data_get($resource, 'environment.name');
+ $this->fqdn = data_get($resource, 'fqdn', null);
+ $this->restart_count = $resource->restart_count;
+ $this->max_restart_count = $resource->max_restart_count;
+ if (str($this->fqdn)->explode(',')->count() > 1) {
+ $this->fqdn = str($this->fqdn)->explode(',')->first();
+ }
+ $this->resource_url = $this->resource->link() ?? base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->resource->uuid}";
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('status_change');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: {$this->resource_name} stopped - restart limit reached ({$this->restart_count}/{$this->max_restart_count})");
+ $mail->view('emails.application-restart-limit-reached', [
+ 'name' => $this->resource_name,
+ 'fqdn' => $this->fqdn,
+ 'resource_url' => $this->resource_url,
+ 'restart_count' => $this->restart_count,
+ 'max_restart_count' => $this->max_restart_count,
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ return new DiscordMessage(
+ title: ':warning: Restart limit reached',
+ description: "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).\n\n[Open Application in Coolify]({$this->resource_url})",
+ color: DiscordMessage::errorColor(),
+ isCritical: true,
+ );
+ }
+
+ public function toTelegram(): array
+ {
+ $message = "Coolify: {$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
+
+ return [
+ 'message' => $message,
+ 'buttons' => [
+ [
+ 'text' => 'Open Application in Coolify',
+ 'url' => $this->resource_url,
+ ],
+ ],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $message = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count}).";
+
+ return new PushoverMessage(
+ title: 'Restart limit reached',
+ level: 'error',
+ message: $message,
+ buttons: [
+ [
+ 'text' => 'Open Application in Coolify',
+ 'url' => $this->resource_url,
+ ],
+ ],
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ $title = 'Restart limit reached';
+ $description = "{$this->resource_name} has been stopped after {$this->restart_count} restarts (limit: {$this->max_restart_count})";
+
+ $description .= "\n\n*Project:* ".data_get($this->resource, 'environment.project.name');
+ $description .= "\n*Environment:* {$this->environment_name}";
+ $description .= "\n*Application URL:* {$this->resource_url}";
+
+ return new SlackMessage(
+ title: $title,
+ description: $description,
+ color: SlackMessage::errorColor()
+ );
+ }
+
+ public function toWebhook(): array
+ {
+ return [
+ 'success' => false,
+ 'message' => 'Restart limit reached',
+ 'event' => 'restart_limit_reached',
+ 'application_name' => $this->resource_name,
+ 'application_uuid' => $this->resource->uuid,
+ 'restart_count' => $this->restart_count,
+ 'max_restart_count' => $this->max_restart_count,
+ 'url' => $this->resource_url,
+ 'project' => data_get($this->resource, 'environment.project.name'),
+ 'environment' => $this->environment_name,
+ 'fqdn' => $this->fqdn,
+ ];
+ }
+}
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 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
]);
}
});
+
+ 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/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 2150126cd..4068572c8 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -54,5 +54,9 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('5', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
+
+ RateLimiter::for('feedback', function (Request $request) {
+ return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
+ });
}
}
diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php
index a6a78a76c..038cc2761 100644
--- a/app/Rules/DockerImageFormat.php
+++ b/app/Rules/DockerImageFormat.php
@@ -2,18 +2,26 @@
namespace App\Rules;
+use App\Support\ValidationPatterns;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
+use Illuminate\Translation\PotentiallyTranslatedString;
class DockerImageFormat implements ValidationRule
{
/**
* Run the validation rule.
*
- * @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
+ * @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
+ if (! is_string($value)) {
+ $fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
+
+ return;
+ }
+
// Check if the value contains ":sha256:" or ":sha" which is incorrect format
if (preg_match('/:sha256?:/i', $value)) {
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
@@ -21,20 +29,21 @@ class DockerImageFormat implements ValidationRule
return;
}
- // Valid formats:
- // 1. image:tag (e.g., nginx:latest)
- // 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
- // 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
- // 4. registry/image@sha256:hash
- // 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
+ $imageName = $value;
+ $tag = null;
- $pattern = '/^
- (?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port
- [a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required)
- (?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash
- $/ix';
+ if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) {
+ $imageName = $matches[1];
+ } else {
+ $lastColon = strrpos($value, ':');
+ $lastSlash = strrpos($value, '/');
+ if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
+ $imageName = substr($value, 0, $lastColon);
+ $tag = substr($value, $lastColon + 1);
+ }
+ }
- if (! preg_match($pattern, $value)) {
+ if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
}
}
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 @@ class SafeWebhookUrl implements ValidationRule
$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 @@ class SafeWebhookUrl implements ValidationRule
}
// 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/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
new file mode 100644
index 000000000..365708758
--- /dev/null
+++ b/app/Services/DeploymentConfiguration/ApplicationConfigurationSnapshot.php
@@ -0,0 +1,446 @@
+
+ */
+ public function toArray(): array
+ {
+ $this->application->load('settings');
+
+ return [
+ 'schema_version' => self::SCHEMA_VERSION,
+ 'resource_type' => Application::class,
+ 'resource_id' => $this->application->id,
+ 'sections' => [
+ 'source' => [
+ 'label' => 'Source',
+ 'items' => $this->sourceItems(),
+ ],
+ 'build' => [
+ 'label' => 'Build',
+ 'items' => $this->buildItems(),
+ ],
+ 'runtime' => [
+ 'label' => 'Runtime',
+ 'items' => $this->runtimeItems(),
+ ],
+ 'domains' => [
+ 'label' => 'Domains & Proxy',
+ 'items' => $this->domainItems(),
+ ],
+ 'environment' => [
+ 'label' => 'Environment Variables',
+ 'items' => $this->environmentItems(),
+ ],
+ ],
+ ];
+ }
+
+ public function hash(): string
+ {
+ return self::hashSnapshot($this->toArray());
+ }
+
+ /**
+ * @param array $snapshot
+ */
+ public static function hashSnapshot(array $snapshot): string
+ {
+ return hash('sha256', json_encode(self::comparableSnapshot($snapshot), JSON_THROW_ON_ERROR));
+ }
+
+ /**
+ * @param array $snapshot
+ * @return array
+ */
+ public static function comparableSnapshot(array $snapshot): array
+ {
+ $sections = collect(data_get($snapshot, 'sections', []))
+ ->mapWithKeys(function (array $section, string $sectionKey): array {
+ $items = collect(data_get($section, 'items', []))
+ ->mapWithKeys(fn (array $item): array => [
+ $item['key'] => [
+ 'compare_value' => $item['compare_value'] ?? null,
+ 'impact' => $item['impact'] ?? 'redeploy',
+ ],
+ ])
+ ->sortKeys()
+ ->all();
+
+ return [$sectionKey => $items];
+ })
+ ->sortKeys()
+ ->all();
+
+ return [
+ 'schema_version' => data_get($snapshot, 'schema_version'),
+ 'sections' => $sections,
+ ];
+ }
+
+ /**
+ * @return array>
+ */
+ private function sourceItems(): array
+ {
+ return [
+ $this->item('git_repository', 'Repository', $this->application->git_repository, 'build'),
+ $this->item('git_branch', 'Branch', $this->application->git_branch, 'build'),
+ $this->item('git_commit_sha', 'Commit SHA', $this->application->git_commit_sha, 'build'),
+ $this->item('private_key_id', 'Private key', $this->application->private_key_id, 'build'),
+ ];
+ }
+
+ /**
+ * @return array>
+ */
+ private function buildItems(): array
+ {
+ return [
+ $this->item('build_pack', 'Build pack', $this->application->build_pack, 'build'),
+ $this->item('static_image', 'Static image', $this->application->static_image, 'build'),
+ $this->item('base_directory', 'Base directory', $this->application->base_directory, 'build'),
+ $this->item('publish_directory', 'Publish directory', $this->application->publish_directory, 'build'),
+ $this->item('install_command', 'Install command', $this->application->install_command, 'build'),
+ $this->item('build_command', 'Build command', $this->application->build_command, 'build'),
+ $this->item('dockerfile', 'Dockerfile', $this->application->dockerfile, 'build', displayValue: $this->summarizeText($this->application->dockerfile), displayFull: $this->application->dockerfile),
+ $this->item('dockerfile_location', 'Dockerfile location', $this->application->dockerfile_location, 'build'),
+ $this->item('dockerfile_target_build', 'Dockerfile target', $this->application->dockerfile_target_build, 'build'),
+ $this->item('docker_compose_location', 'Docker Compose location', $this->application->docker_compose_location, 'build'),
+ // The generated docker_compose is intentionally excluded: it is re-rendered
+ // from git on every parse (resolved env, generated labels, deployment context),
+ // so comparing it would flag a permanent change for git-based compose apps.
+ $this->item('docker_compose_raw', 'Docker Compose', $this->application->docker_compose_raw, 'build', displayValue: $this->summarizeText($this->application->docker_compose_raw), displayFull: $this->application->docker_compose_raw, diffMode: 'lines'),
+ $this->item('docker_compose_custom_build_command', 'Docker Compose custom build command', $this->application->docker_compose_custom_build_command, 'build'),
+ $this->item('custom_docker_run_options', 'Custom Docker run options', $this->application->custom_docker_run_options, 'build'),
+ $this->item('use_build_secrets', 'Use build secrets', data_get($this->application, 'settings.use_build_secrets'), 'build'),
+ $this->item('inject_build_args_to_dockerfile', 'Inject build args to Dockerfile', data_get($this->application, 'settings.inject_build_args_to_dockerfile'), 'build'),
+ $this->item('include_source_commit_in_build', 'Include source commit in build', data_get($this->application, 'settings.include_source_commit_in_build'), 'build'),
+ $this->item('disable_build_cache', 'Disable build cache', data_get($this->application, 'settings.disable_build_cache'), 'build'),
+ $this->item('is_build_server_enabled', 'Build server', data_get($this->application, 'settings.is_build_server_enabled'), 'build'),
+ ];
+ }
+
+ /**
+ * @return array>
+ */
+ private function runtimeItems(): array
+ {
+ return [
+ $this->item('start_command', 'Start command', $this->application->start_command, 'redeploy'),
+ $this->item('docker_compose_custom_start_command', 'Docker Compose custom start command', $this->application->docker_compose_custom_start_command, 'redeploy'),
+ $this->item('ports_exposes', 'Exposed ports', $this->application->ports_exposes, 'redeploy'),
+ $this->item('ports_mappings', 'Port mappings', $this->application->ports_mappings, 'redeploy'),
+ $this->item('custom_network_aliases', 'Network aliases', $this->application->custom_network_aliases, 'redeploy'),
+ $this->item('connect_to_docker_network', 'Connect to Docker network', data_get($this->application, 'settings.connect_to_docker_network'), 'redeploy'),
+ $this->item('custom_internal_name', 'Custom container name', data_get($this->application, 'settings.custom_internal_name'), 'redeploy'),
+ $this->item('is_raw_compose_deployment_enabled', 'Raw Compose deployment', data_get($this->application, 'settings.is_raw_compose_deployment_enabled'), 'redeploy'),
+ $this->item('is_gpu_enabled', 'GPU enabled', data_get($this->application, 'settings.is_gpu_enabled'), 'redeploy'),
+ $this->item('gpu_driver', 'GPU driver', data_get($this->application, 'settings.gpu_driver'), 'redeploy'),
+ $this->item('gpu_count', 'GPU count', data_get($this->application, 'settings.gpu_count'), 'redeploy'),
+ $this->item('gpu_device_ids', 'GPU device IDs', data_get($this->application, 'settings.gpu_device_ids'), 'redeploy'),
+ $this->item('gpu_options', 'GPU options', data_get($this->application, 'settings.gpu_options'), 'redeploy'),
+ ...$this->healthCheckItems(),
+ ...$this->limitItems(),
+ ];
+ }
+
+ /**
+ * @return array>
+ */
+ private function domainItems(): array
+ {
+ return [
+ $this->item('fqdn', 'Domains', $this->application->fqdn, 'redeploy'),
+ $this->item('docker_compose_domains', 'Service domains', $this->decodedComposeDomains(), 'redeploy', displayValue: $this->summarizeText($this->composeDomainsText()), displayFull: $this->composeDomainsText(), diffMode: 'lines'),
+ $this->item('redirect', 'Redirect', $this->application->redirect, 'redeploy'),
+ $this->item('custom_labels', 'Container labels', $this->application->custom_labels, 'redeploy', displayValue: $this->summarizeText($this->decodeCustomLabels($this->application->custom_labels)), displayFull: $this->decodeCustomLabels($this->application->custom_labels), diffMode: 'lines'),
+ $this->item('custom_nginx_configuration', 'Custom Nginx configuration', $this->application->custom_nginx_configuration, 'redeploy', displayValue: $this->summarizeText($this->application->custom_nginx_configuration), displayFull: $this->application->custom_nginx_configuration),
+ $this->item('is_force_https_enabled', 'Force HTTPS', data_get($this->application, 'settings.is_force_https_enabled'), 'redeploy'),
+ $this->item('is_gzip_enabled', 'Gzip', data_get($this->application, 'settings.is_gzip_enabled'), 'redeploy'),
+ $this->item('is_stripprefix_enabled', 'Strip prefix', data_get($this->application, 'settings.is_stripprefix_enabled'), 'redeploy'),
+ $this->item('is_http_basic_auth_enabled', 'HTTP basic auth', $this->application->is_http_basic_auth_enabled, 'redeploy'),
+ $this->item('http_basic_auth_username', 'HTTP basic auth username', $this->application->http_basic_auth_username, 'redeploy'),
+ $this->item('http_basic_auth_password', 'HTTP basic auth password', $this->application->http_basic_auth_password, 'redeploy', sensitive: true),
+ ];
+ }
+
+ /**
+ * @return array>
+ */
+ private function environmentItems(): array
+ {
+ return $this->application->environment_variables()
+ ->get()
+ ->sortBy('key', SORT_NATURAL | SORT_FLAG_CASE)
+ ->values()
+ ->map(fn (EnvironmentVariable $environmentVariable): array => $this->environmentItem($environmentVariable))
+ ->all();
+ }
+
+ /**
+ * @return array>
+ */
+ private function healthCheckItems(): array
+ {
+ return collect([
+ 'health_check_enabled' => 'Health check enabled',
+ 'health_check_path' => 'Health check path',
+ 'health_check_port' => 'Health check port',
+ 'health_check_host' => 'Health check host',
+ 'health_check_method' => 'Health check method',
+ 'health_check_return_code' => 'Health check return code',
+ 'health_check_scheme' => 'Health check scheme',
+ 'health_check_response_text' => 'Health check response text',
+ 'health_check_interval' => 'Health check interval',
+ 'health_check_timeout' => 'Health check timeout',
+ 'health_check_retries' => 'Health check retries',
+ 'health_check_start_period' => 'Health check start period',
+ 'health_check_type' => 'Health check type',
+ 'health_check_command' => 'Health check command',
+ ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
+ }
+
+ /**
+ * @return array>
+ */
+ private function limitItems(): array
+ {
+ return collect([
+ 'limits_memory' => 'Memory limit',
+ 'limits_memory_swap' => 'Memory swap limit',
+ 'limits_memory_swappiness' => 'Memory swappiness',
+ 'limits_memory_reservation' => 'Memory reservation',
+ 'limits_cpus' => 'CPU limit',
+ 'limits_cpuset' => 'CPU set',
+ 'limits_cpu_shares' => 'CPU shares',
+ 'swarm_replicas' => 'Swarm replicas',
+ 'swarm_placement_constraints' => 'Swarm placement constraints',
+ ])->map(fn (string $label, string $key): array => $this->item($key, $label, data_get($this->application, $key), 'redeploy'))->values()->all();
+ }
+
+ /**
+ * @return array
+ */
+ private function environmentItem(EnvironmentVariable $environmentVariable): array
+ {
+ $impact = $environmentVariable->is_buildtime ? 'build' : 'redeploy';
+ $locked = (bool) $environmentVariable->is_shown_once;
+ $compareValue = [
+ 'value_hash' => $this->sensitiveHash($environmentVariable->value),
+ 'is_multiline' => $environmentVariable->is_multiline,
+ 'is_literal' => $environmentVariable->is_literal,
+ 'is_buildtime' => $environmentVariable->is_buildtime,
+ 'is_runtime' => $environmentVariable->is_runtime,
+ ];
+
+ // Locked (is_shown_once) variables are always redacted and never store a value.
+ if ($locked) {
+ return $this->item(
+ key: (string) $environmentVariable->key,
+ label: (string) $environmentVariable->key,
+ value: $compareValue,
+ impact: $impact,
+ sensitive: true,
+ displayValue: $this->environmentDisplayValue($environmentVariable),
+ );
+ }
+
+ // Unlocked variables expose their value so owners/admins can see the change.
+ // The compare value is pre-hashed (identical formula to the locked branch) so
+ // change detection stays stable and never carries the raw value; members are
+ // redacted at render time in ConfigurationChecker; the column is encrypted at rest.
+ // The value and each scope flag are rendered as their own line and diffed by line,
+ // so a change to one or more attributes shows exactly what changed (one line each).
+ $value = (string) $environmentVariable->value;
+
+ return $this->item(
+ key: (string) $environmentVariable->key,
+ label: (string) $environmentVariable->key,
+ value: $this->sensitiveHash($this->normalizeValue($compareValue)),
+ impact: $impact,
+ sensitive: false,
+ displayValue: $this->summarizeText($value),
+ displayFull: $this->environmentLines($environmentVariable),
+ diffMode: 'lines',
+ );
+ }
+
+ /**
+ * One line per attribute so the line diff surfaces exactly which value/flags changed.
+ */
+ private function environmentLines(EnvironmentVariable $environmentVariable): string
+ {
+ $lines = collect();
+
+ $value = (string) $environmentVariable->value;
+ if (filled($value)) {
+ $lines->push($value);
+ }
+
+ $lines->push('Available at build: '.($environmentVariable->is_buildtime ? 'enabled' : 'disabled'));
+ $lines->push('Available at runtime: '.($environmentVariable->is_runtime ? 'enabled' : 'disabled'));
+ $lines->push('Multiline: '.($environmentVariable->is_multiline ? 'enabled' : 'disabled'));
+ $lines->push('Literal: '.($environmentVariable->is_literal ? 'enabled' : 'disabled'));
+
+ return $lines->implode("\n");
+ }
+
+ /**
+ * @return array
+ */
+ private function item(string $key, string $label, mixed $value, string $impact, bool $sensitive = false, mixed $displayValue = null, ?string $displayFull = null, string $diffMode = 'default'): array
+ {
+ $normalizedValue = $this->normalizeValue($value);
+
+ return [
+ 'key' => $key,
+ 'label' => $label,
+ 'impact' => $impact,
+ 'sensitive' => $sensitive,
+ 'diff_mode' => $diffMode,
+ 'compare_value' => $sensitive ? $this->sensitiveHash($normalizedValue) : $normalizedValue,
+ 'display_value' => $displayValue ?? $this->displayValue($normalizedValue),
+ 'display_full' => $sensitive ? null : $this->expandableText($displayFull ?? $this->stringifyValue($normalizedValue)),
+ ];
+ }
+
+ private function environmentDisplayValue(EnvironmentVariable $environmentVariable): string
+ {
+ $flags = $this->environmentFlags($environmentVariable);
+
+ return $flags ? "Hidden ({$flags})" : 'Hidden';
+ }
+
+ private function environmentFlags(EnvironmentVariable $environmentVariable): string
+ {
+ return collect([
+ $environmentVariable->is_buildtime ? 'build-time' : null,
+ $environmentVariable->is_runtime ? 'runtime' : null,
+ $environmentVariable->is_multiline ? 'multiline' : null,
+ $environmentVariable->is_literal ? 'literal' : null,
+ ])->filter()->implode(', ');
+ }
+
+ private function sensitiveHash(mixed $value): string
+ {
+ return hash_hmac('sha256', json_encode($value, JSON_THROW_ON_ERROR), (string) config('app.key', 'coolify'));
+ }
+
+ private function normalizeValue(mixed $value): mixed
+ {
+ if ($value === '') {
+ return null;
+ }
+
+ if (is_bool($value) || is_numeric($value) || $value === null || is_string($value)) {
+ return $value;
+ }
+
+ if (is_array($value)) {
+ return Arr::sortRecursive($value);
+ }
+
+ return (string) $value;
+ }
+
+ private function displayValue(mixed $value): string
+ {
+ if ($value === null) {
+ return '-';
+ }
+
+ if (is_bool($value)) {
+ return $value ? 'Enabled' : 'Disabled';
+ }
+
+ if (is_array($value)) {
+ return $this->summarizeText(json_encode($value, JSON_THROW_ON_ERROR));
+ }
+
+ return $this->summarizeText((string) $value);
+ }
+
+ private function stringifyValue(mixed $value): ?string
+ {
+ if ($value === null || is_bool($value)) {
+ return null;
+ }
+
+ if (is_array($value)) {
+ return json_encode($value, JSON_THROW_ON_ERROR);
+ }
+
+ return (string) $value;
+ }
+
+ /**
+ * @return array|null
+ */
+ private function decodedComposeDomains(): ?array
+ {
+ if (blank($this->application->docker_compose_domains)) {
+ return null;
+ }
+
+ $decoded = json_decode((string) $this->application->docker_compose_domains, true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ private function composeDomainsText(): ?string
+ {
+ $decoded = $this->decodedComposeDomains();
+
+ if (blank($decoded)) {
+ return null;
+ }
+
+ return collect($decoded)
+ ->map(fn ($value, $service): string => $service.': '.(filled(data_get($value, 'domain')) ? data_get($value, 'domain') : '-'))
+ ->sort()
+ ->implode("\n");
+ }
+
+ private function decodeCustomLabels(?string $value): ?string
+ {
+ if (blank($value)) {
+ return null;
+ }
+
+ $decoded = base64_decode($value, true);
+
+ return $decoded === false ? $value : $decoded;
+ }
+
+ private function summarizeText(?string $value): string
+ {
+ if (blank($value)) {
+ return '-';
+ }
+
+ $value = trim((string) $value);
+ $lines = substr_count($value, "\n") + 1;
+
+ if ($lines > 1) {
+ return str($value)->limit(80)." ({$lines} lines)";
+ }
+
+ return str($value)->limit(self::SINGLE_LINE_LIMIT)->value();
+ }
+}
diff --git a/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
new file mode 100644
index 000000000..6960a8f1b
--- /dev/null
+++ b/app/Services/DeploymentConfiguration/Concerns/SummarizesDiffText.php
@@ -0,0 +1,32 @@
+ self::SINGLE_LINE_LIMIT) {
+ return $value;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiff.php b/app/Services/DeploymentConfiguration/ConfigurationDiff.php
new file mode 100644
index 000000000..3f0477ba3
--- /dev/null
+++ b/app/Services/DeploymentConfiguration/ConfigurationDiff.php
@@ -0,0 +1,96 @@
+> $changes
+ */
+ public function __construct(
+ protected array $changes = [],
+ protected bool $legacyFallback = false,
+ ) {}
+
+ public static function unchanged(): self
+ {
+ return new self;
+ }
+
+ public static function legacy(bool $changed): self
+ {
+ if (! $changed) {
+ return self::unchanged();
+ }
+
+ return new self([
+ [
+ 'key' => 'legacy.configuration',
+ 'section' => 'configuration',
+ 'section_label' => 'Configuration',
+ 'label' => 'Configuration',
+ 'type' => 'changed',
+ 'impact' => 'build',
+ 'sensitive' => false,
+ 'old_display_value' => 'Previously deployed configuration',
+ 'new_display_value' => 'Current configuration',
+ ],
+ ], true);
+ }
+
+ /**
+ * @param array> $changes
+ */
+ public static function fromChanges(array $changes): self
+ {
+ return new self(array_values($changes));
+ }
+
+ public function isChanged(): bool
+ {
+ return $this->changes !== [];
+ }
+
+ public function isLegacyFallback(): bool
+ {
+ return $this->legacyFallback;
+ }
+
+ public function count(): int
+ {
+ return count($this->changes);
+ }
+
+ public function requiresBuild(): bool
+ {
+ return collect($this->changes)->contains(fn (array $change): bool => $change['impact'] === 'build');
+ }
+
+ public function requiresRedeploy(): bool
+ {
+ return $this->isChanged();
+ }
+
+ /**
+ * @return array>
+ */
+ public function changes(): array
+ {
+ return $this->changes;
+ }
+
+ /**
+ * @return array{changed: bool, count: int, requires_build: bool, requires_redeploy: bool, legacy_fallback: bool, changes: array>}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'changed' => $this->isChanged(),
+ 'count' => $this->count(),
+ 'requires_build' => $this->requiresBuild(),
+ 'requires_redeploy' => $this->requiresRedeploy(),
+ 'legacy_fallback' => $this->isLegacyFallback(),
+ 'changes' => $this->changes(),
+ ];
+ }
+}
diff --git a/app/Services/DeploymentConfiguration/ConfigurationDiffer.php b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php
new file mode 100644
index 000000000..e9707edbe
--- /dev/null
+++ b/app/Services/DeploymentConfiguration/ConfigurationDiffer.php
@@ -0,0 +1,157 @@
+
+ */
+ private const IGNORED_KEYS = ['build.docker_compose'];
+
+ /**
+ * @param array $previousSnapshot
+ * @param array $currentSnapshot
+ */
+ public function diff(array $previousSnapshot, array $currentSnapshot): ConfigurationDiff
+ {
+ $previousItems = $this->flattenItems($previousSnapshot);
+ $currentItems = $this->flattenItems($currentSnapshot);
+ $keys = collect(array_keys($previousItems))->merge(array_keys($currentItems))->unique()->sort();
+ $changes = [];
+
+ foreach ($keys as $key) {
+ if (in_array($key, self::IGNORED_KEYS, true)) {
+ continue;
+ }
+
+ $previous = $previousItems[$key] ?? null;
+ $current = $currentItems[$key] ?? null;
+
+ if (($previous['compare_value'] ?? null) === ($current['compare_value'] ?? null)) {
+ continue;
+ }
+
+ $item = $current ?? $previous;
+ $sensitive = (bool) data_get($item, 'sensitive', false);
+ $type = $previous === null ? 'added' : ($current === null ? 'removed' : 'changed');
+ $displaySummary = $sensitive && $type === 'changed' ? 'Changed' : null;
+ $diffMode = data_get($item, 'diff_mode', 'default');
+
+ $oldFull = null;
+ $newFull = null;
+
+ if ($sensitive) {
+ $oldDisplay = $previous === null ? '-' : '••••••••';
+ $newDisplay = $current === null ? '-' : '••••••••';
+ } elseif ($diffMode === 'lines' && $type === 'changed') {
+ [$oldDisplay, $newDisplay] = $this->changedLines(
+ data_get($previous, 'display_full'),
+ data_get($current, 'display_full'),
+ );
+
+ // No line-level difference (e.g. only reordering) — fall back to the summary.
+ if ($oldDisplay === '-' && $newDisplay === '-') {
+ $oldDisplay = data_get($previous, 'display_value', '-');
+ $newDisplay = data_get($current, 'display_value', '-');
+ }
+
+ // Expansion reveals the full changed lines, not the entire value.
+ $oldFull = $this->expandableText($oldDisplay);
+ $newFull = $this->expandableText($newDisplay);
+ } else {
+ $oldDisplay = data_get($previous, 'display_value', '-');
+ $newDisplay = data_get($current, 'display_value', '-');
+ $oldFull = data_get($previous, 'display_full');
+ $newFull = data_get($current, 'display_full');
+ }
+
+ $expandable = ! $sensitive && (filled($oldFull) || filled($newFull));
+
+ $changes[] = [
+ 'key' => $key,
+ 'section' => data_get($item, 'section'),
+ 'section_label' => data_get($item, 'section_label'),
+ 'label' => data_get($item, 'label'),
+ 'type' => $type,
+ 'impact' => data_get($item, 'impact', 'redeploy'),
+ 'sensitive' => $sensitive,
+ 'display_summary' => $displaySummary,
+ 'old_display_value' => $oldDisplay,
+ 'new_display_value' => $newDisplay,
+ 'old_full_value' => $oldFull,
+ 'new_full_value' => $newFull,
+ 'expandable' => $expandable,
+ ];
+ }
+
+ return ConfigurationDiff::fromChanges($changes);
+ }
+
+ /**
+ * Reduce two multi-line values to only the lines that differ, so the modal
+ * shows just the changed container labels instead of the whole block.
+ *
+ * @return array{0: string, 1: string}
+ */
+ private function changedLines(?string $old, ?string $new): array
+ {
+ $oldLines = $this->textLines($old);
+ $newLines = $this->textLines($new);
+
+ $removed = array_values(array_diff($oldLines, $newLines));
+ $added = array_values(array_diff($newLines, $oldLines));
+
+ return [
+ $removed === [] ? '-' : implode("\n", $removed),
+ $added === [] ? '-' : implode("\n", $added),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function textLines(?string $value): array
+ {
+ if (blank($value)) {
+ return [];
+ }
+
+ // Keep leading indentation (meaningful for YAML/compose), drop trailing whitespace.
+ return collect(preg_split('/\r\n|\r|\n/', (string) $value))
+ ->map(fn (string $line): string => rtrim($line))
+ ->filter(fn (string $line): bool => trim($line) !== '')
+ ->values()
+ ->all();
+ }
+
+ /**
+ * @param array $snapshot
+ * @return array>
+ */
+ private function flattenItems(array $snapshot): array
+ {
+ return collect(data_get($snapshot, 'sections', []))
+ ->flatMap(function (array $section, string $sectionKey): array {
+ return collect(data_get($section, 'items', []))
+ ->mapWithKeys(function (array $item) use ($section, $sectionKey): array {
+ $key = $sectionKey.'.'.$item['key'];
+
+ return [$key => array_merge($item, [
+ 'section' => $sectionKey,
+ 'section_label' => data_get($section, 'label', str($sectionKey)->headline()->value()),
+ ])];
+ })
+ ->all();
+ })
+ ->all();
+ }
+}
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 88121384f..7e3974dd7 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,271 @@ 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!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
+
+ /**
+ * Pattern for Docker image repository names without a tag.
+ *
+ * Allows an optional registry host/port followed by lowercase repository
+ * path components. A trailing @sha256 marker is accepted for existing
+ * digest-based dockerimage records that store the digest hash separately.
+ */
+ public const DOCKER_IMAGE_NAME_PATTERN = '/\A(?=.{1,255}\z)(?:(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]+)?\/)?[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*)*)(?:@sha256)?\z/';
+
+ /**
+ * Pattern for Docker image tags.
+ *
+ * Docker tags may contain letters, digits, underscores, dots, and hyphens,
+ * must start with an alphanumeric/underscore, and are limited to 128 chars.
+ */
+ public const DOCKER_IMAGE_TAG_PATTERN = '/\A[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}\z/';
+
+ /**
+ * 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 Docker image repository names without tags.
+ */
+ public static function dockerImageNameRules(bool $required = false, int $maxLength = 255): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::DOCKER_IMAGE_NAME_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation rules for Docker image tags.
+ */
+ public static function dockerImageTagRules(bool $required = false, int $maxLength = 128): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "max:$maxLength";
+ $rules[] = 'regex:'.self::DOCKER_IMAGE_TAG_PATTERN;
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for Docker image fields.
+ */
+ public static function dockerImageMessages(string $nameField = 'docker_registry_image_name', string $tagField = 'docker_registry_image_tag'): array
+ {
+ return [
+ "{$nameField}.regex" => 'The Docker registry image name must be a valid image repository without a tag and may not contain shell metacharacters.',
+ "{$tagField}.regex" => 'The Docker registry image tag must be a valid Docker tag and may not contain shell metacharacters.',
+ ];
+ }
+
+ /**
+ * Check if a string is a valid Docker image repository name without a tag.
+ */
+ public static function isValidDockerImageName(?string $value): bool
+ {
+ if (blank($value)) {
+ return true;
+ }
+
+ return preg_match(self::DOCKER_IMAGE_NAME_PATTERN, $value) === 1;
+ }
+
+ /**
+ * Check if a string is a valid Docker image tag.
+ */
+ public static function isValidDockerImageTag(?string $value): bool
+ {
+ if (blank($value)) {
+ return true;
+ }
+
+ return preg_match(self::DOCKER_IMAGE_TAG_PATTERN, $value) === 1;
+ }
+
+ /**
+ * 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/DeletesUserSessions.php b/app/Traits/DeletesUserSessions.php
index e9ec0d946..44ff5f727 100644
--- a/app/Traits/DeletesUserSessions.php
+++ b/app/Traits/DeletesUserSessions.php
@@ -2,6 +2,7 @@
namespace App\Traits;
+use App\Actions\User\RevokeUserTeamTokens;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Session;
@@ -17,6 +18,7 @@ trait DeletesUserSessions
Session::invalidate();
Session::regenerateToken();
DB::table('sessions')->where('user_id', $this->id)->delete();
+ RevokeUserTeamTokens::forUser($this->id);
}
/**
diff --git a/app/Traits/HasDatabaseHealthCheck.php b/app/Traits/HasDatabaseHealthCheck.php
new file mode 100644
index 000000000..62ca345ed
--- /dev/null
+++ b/app/Traits/HasDatabaseHealthCheck.php
@@ -0,0 +1,45 @@
+health_check_enabled ?? true);
+ }
+
+ /**
+ * Build the Docker Compose healthcheck block for the given probe command.
+ *
+ * @param array $test The Docker `test` array (e.g. ['CMD', 'pg_isready']).
+ * @return array
+ */
+ public function healthCheckConfiguration(array $test): array
+ {
+ return [
+ 'test' => $test,
+ 'interval' => ($this->health_check_interval ?? 15).'s',
+ 'timeout' => ($this->health_check_timeout ?? 5).'s',
+ 'retries' => $this->health_check_retries ?? 5,
+ 'start_period' => ($this->health_check_start_period ?? 5).'s',
+ ];
+ }
+
+ protected function healthCheckConfigurationHash(): string
+ {
+ return implode('|', [
+ (int) ($this->health_check_enabled ?? true),
+ $this->health_check_interval ?? 15,
+ $this->health_check_timeout ?? 5,
+ $this->health_check_retries ?? 5,
+ $this->health_check_start_period ?? 5,
+ ]);
+ }
+}
diff --git a/app/Traits/HasDatabaseStatusInfo.php b/app/Traits/HasDatabaseStatusInfo.php
new file mode 100644
index 000000000..e46cccf0c
--- /dev/null
+++ b/app/Traits/HasDatabaseStatusInfo.php
@@ -0,0 +1,172 @@
+ 'refresh'];
+
+ $user = Auth::user();
+ if (! $user) {
+ return $listeners;
+ }
+
+ $listeners["echo-private:user.{$user->id},DatabaseStatusChanged"] = 'refresh';
+
+ $team = $user->currentTeam();
+ if ($team) {
+ $listeners["echo-private:team.{$team->id},ServiceChecked"] = 'refresh';
+ }
+
+ return $listeners;
+ }
+
+ public function mount(): void
+ {
+ $this->refresh();
+ }
+
+ public function refresh(): void
+ {
+ $this->database->refresh();
+ $this->dbUrl = $this->database->internal_db_url;
+ $this->dbUrlPublic = $this->database->external_db_url;
+ if ($this->supportsSsl()) {
+ $this->enableSsl = (bool) $this->database->enable_ssl;
+ $this->certificateValidUntil = $this->database->sslCertificates()->first()?->valid_until;
+ $this->afterRefresh();
+ }
+ }
+
+ /**
+ * Hook for subclasses with extra status-derived properties (e.g. sslMode).
+ */
+ protected function afterRefresh(): void {}
+
+ public function instantSaveSSL(): void
+ {
+ try {
+ $this->authorize('update', $this->database);
+ $this->database->enable_ssl = $this->enableSsl;
+ $this->applyExtraSslAttributes();
+ $this->database->save();
+ $this->dispatch('success', 'SSL configuration updated.');
+ } catch (Exception $e) {
+ handleError($e, $this);
+ }
+ }
+
+ /**
+ * Hook for subclasses with additional SSL columns to persist (e.g. ssl_mode).
+ */
+ protected function applyExtraSslAttributes(): void {}
+
+ public function regenerateSslCertificate(): void
+ {
+ try {
+ $this->authorize('update', $this->database);
+
+ $existingCert = $this->database->sslCertificates()->first();
+
+ if (! $existingCert) {
+ $this->dispatch('error', 'No existing SSL certificate found for this database.');
+
+ return;
+ }
+
+ $server = $this->database->destination->server;
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
+
+ if (! $caCert) {
+ $server->generateCaCertificate();
+ $caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
+ }
+
+ if (! $caCert) {
+ $this->dispatch('error', 'No CA certificate found for this database. Please generate a CA certificate for this server in the server/advanced page.');
+
+ return;
+ }
+
+ SslHelper::generateSslCertificate(
+ commonName: $existingCert->common_name,
+ subjectAlternativeNames: $existingCert->subject_alternative_names ?? [],
+ resourceType: $existingCert->resource_type,
+ resourceId: $existingCert->resource_id,
+ serverId: $existingCert->server_id,
+ caCert: $caCert->ssl_certificate,
+ caKey: $caCert->ssl_private_key,
+ configurationDir: $existingCert->configuration_dir,
+ mountPath: $existingCert->mount_path,
+ isPemKeyFileRequired: true,
+ );
+
+ $this->refresh();
+ $this->dispatch('success', 'SSL certificates regenerated. Restart database to apply changes.');
+ } catch (Exception $e) {
+ handleError($e, $this);
+ }
+ }
+
+ public function render(): View
+ {
+ return view('livewire.project.database.status-info', [
+ 'label' => $this->databaseLabel(),
+ 'supportsSsl' => $this->supportsSsl(),
+ 'sslModeOptions' => $this->sslModeOptions(),
+ 'sslModeHelper' => $this->sslModeHelper(),
+ 'showPublicUrlPlaceholder' => $this->showPublicUrlPlaceholder(),
+ 'isExited' => str($this->database->status)->contains('exited'),
+ ]);
+ }
+}
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 @@ trait HasMetrics
$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 @@ trait HasMetrics
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/app/Traits/SshRetryable.php b/app/Traits/SshRetryable.php
index 2092dc5f3..37303c7e6 100644
--- a/app/Traits/SshRetryable.php
+++ b/app/Traits/SshRetryable.php
@@ -40,6 +40,7 @@ trait SshRetryable
'Remote host closed connection',
'Authentication failed',
'Too many authentication failures',
+ 'SSH command failed with exit code: 255',
];
$lowerErrorOutput = strtolower($errorOutput);
diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php
index 8088e6b99..6a288a064 100644
--- a/bootstrap/helpers/api.php
+++ b/bootstrap/helpers/api.php
@@ -3,15 +3,23 @@
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
+use App\Rules\ValidGitBranch;
+use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
function getTeamIdFromToken()
{
- $token = auth()->user()->currentAccessToken();
+ $user = auth()->user();
+ $token = $user?->currentAccessToken();
+ $teamId = data_get($token, 'team_id');
- return data_get($token, 'team_id');
+ if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
+ return null;
+ }
+
+ return $teamId;
}
function invalidTokenResponse()
{
@@ -83,7 +91,7 @@ function sharedDataApplications()
{
return [
'git_repository' => 'string',
- 'git_branch' => 'string',
+ 'git_branch' => ['string', new ValidGitBranch],
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'is_spa' => 'boolean',
@@ -93,16 +101,16 @@ function sharedDataApplications()
'domains' => 'string|nullable',
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
- 'docker_registry_image_name' => 'string|nullable',
- 'docker_registry_image_tag' => 'string|nullable',
- 'install_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
- 'build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
- 'start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
+ 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
+ 'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
+ 'install_command' => ValidationPatterns::shellSafeCommandRules(),
+ 'build_command' => ValidationPatterns::shellSafeCommandRules(),
+ 'start_command' => ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
- 'base_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
- 'publish_directory' => \App\Support\ValidationPatterns::directoryPathRules(),
+ 'base_directory' => ValidationPatterns::directoryPathRules(),
+ 'publish_directory' => ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
@@ -125,26 +133,26 @@ function sharedDataApplications()
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
- 'custom_docker_run_options' => \App\Support\ValidationPatterns::shellSafeCommandRules(2000),
+ 'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
- 'post_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
+ 'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
- 'pre_deployment_command_container' => \App\Support\ValidationPatterns::containerNameRules(),
+ 'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => 'string|nullable',
'manual_webhook_secret_gitlab' => 'string|nullable',
'manual_webhook_secret_bitbucket' => 'string|nullable',
'manual_webhook_secret_gitea' => 'string|nullable',
- 'dockerfile_location' => \App\Support\ValidationPatterns::filePathRules(),
- 'dockerfile_target_build' => \App\Support\ValidationPatterns::dockerTargetRules(),
- 'docker_compose_location' => \App\Support\ValidationPatterns::filePathRules(),
+ 'dockerfile_location' => ValidationPatterns::filePathRules(),
+ 'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
+ 'docker_compose_location' => ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
- 'docker_compose_custom_start_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
- 'docker_compose_custom_build_command' => \App\Support\ValidationPatterns::shellSafeCommandRules(),
+ 'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
+ 'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
- 'is_preserve_repository_enabled' => 'boolean'
+ 'is_preserve_repository_enabled' => 'boolean',
];
}
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 App\Models\StandaloneDocker;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
-function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?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..79049e8c7 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 * * * *',
@@ -16,6 +35,9 @@ const VALID_CRON_STRINGS = [
'@yearly' => '0 0 1 1 *',
];
const RESTART_MODE = 'unless-stopped';
+const DEFAULT_STOP_GRACE_PERIOD_SECONDS = 30;
+const MIN_STOP_GRACE_PERIOD_SECONDS = 1;
+const MAX_STOP_GRACE_PERIOD_SECONDS = 3600;
const DATABASE_DOCKER_IMAGES = [
'bitnami/mariadb',
diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
index 5df36db33..4d5e085f3 100644
--- a/bootstrap/helpers/databases.php
+++ b/bootstrap/helpers/databases.php
@@ -3,6 +3,7 @@
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\Server;
+use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
@@ -12,18 +13,19 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
-function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
+function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
- $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
$database = new StandalonePostgresql;
$database->uuid = (new Cuid2);
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage;
- $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->postgres_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
return $database;
}
-function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
+function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->name = 'redis-database-'.$database->uuid;
- $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $redis_password = Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
@@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
+function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMongodb;
$database->uuid = (new Cuid2);
$database->name = 'mongodb-database-'.$database->uuid;
- $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
return $database;
}
-function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
+function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMysql;
$database->uuid = (new Cuid2);
$database->name = 'mysql-database-'.$database->uuid;
- $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
- $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mysql_root_password = Str::password(length: 64, symbols: false);
+ $database->mysql_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
+function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMariadb;
$database->uuid = (new Cuid2);
$database->name = 'mariadb-database-'.$database->uuid;
- $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
- $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mariadb_root_password = Str::password(length: 64, symbols: false);
+ $database->mariadb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
return $database;
}
-function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
+function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneKeydb;
$database->uuid = (new Cuid2);
$database->name = 'keydb-database-'.$database->uuid;
- $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->keydb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
+function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneDragonfly;
$database->uuid = (new Cuid2);
$database->name = 'dragonfly-database-'.$database->uuid;
- $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->dragonfly_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
return $database;
}
-function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
+function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneClickhouse;
$database->uuid = (new Cuid2);
$database->name = 'clickhouse-database-'.$database->uuid;
- $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -279,7 +274,7 @@ function removeOldBackups($backup): void
->whereNull('s3_uploaded')
->delete();
- } catch (\Exception $e) {
+ } catch (Exception $e) {
throw $e;
}
}
@@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection
$processedBackups = collect();
$server = null;
- if ($backup->database_type === \App\Models\ServiceDatabase::class) {
+ if ($backup->database_type === ServiceDatabase::class) {
$server = $backup->database->service->server;
} else {
$server = $backup->database->destination->server;
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 5905ed3c1..2cf159bfd 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -86,7 +86,7 @@ function format_docker_command_output_to_json($rawOutput): Collection
return $outputLines
->reject(fn ($line) => empty($line))
->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR));
- } catch (\Throwable) {
+ } catch (Throwable) {
return collect([]);
}
}
@@ -123,7 +123,7 @@ function format_docker_envs_to_json($rawOutput)
return [$env[0] => $env[1]];
});
- } catch (\Throwable) {
+ } catch (Throwable) {
return collect([]);
}
}
@@ -255,12 +255,12 @@ function defaultLabels($id, $name, string $projectName, string $resourceName, st
function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
{
- if ($resource->getMorphClass() === \App\Models\ServiceApplication::class) {
+ if ($resource->getMorphClass() === ServiceApplication::class) {
$uuid = data_get($resource, 'uuid');
$server = data_get($resource, 'service.server');
$environment_variables = data_get($resource, 'service.environment_variables');
$type = $resource->serviceType();
- } elseif ($resource->getMorphClass() === \App\Models\Application::class) {
+ } elseif ($resource->getMorphClass() === Application::class) {
$uuid = data_get($resource, 'uuid');
$server = data_get($resource, 'destination.server');
$environment_variables = data_get($resource, 'environment_variables');
@@ -641,7 +641,7 @@ function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_
}
}
}
- } catch (\Throwable) {
+ } catch (Throwable) {
continue;
}
}
@@ -1000,6 +1000,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--ulimit',
'--device',
'--shm-size',
+ '--dns',
]);
$mapping = collect([
'--cap-add' => 'cap_add',
@@ -1013,6 +1014,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null)
'--ip' => 'ip',
'--ip6' => 'ip6',
'--shm-size' => 'shm_size',
+ '--dns' => 'dns',
'--gpus' => 'gpus',
'--hostname' => 'hostname',
'--entrypoint' => 'entrypoint',
@@ -1219,7 +1221,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
$server = Server::ownedByCurrentTeam()->find($server_id);
try {
if (! $server) {
- throw new \Exception('Server not found');
+ throw new Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
@@ -1235,7 +1237,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
], $server);
return 'OK';
- } catch (\Throwable $e) {
+ } catch (Throwable $e) {
return $e->getMessage();
} finally {
if (filled($server)) {
@@ -1351,10 +1353,10 @@ function escapeBashDoubleQuoted(?string $value): string
* Generate Docker build arguments from environment variables collection
* Returns only keys (no values) since values are sourced from environment via export
*
- * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
- * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings (keys only)
+ * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
+ * @return Collection Collection of formatted --build-arg strings (keys only)
*/
-function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
+function generateDockerBuildArgs($variables): Collection
{
$variables = collect($variables);
@@ -1369,7 +1371,7 @@ function generateDockerBuildArgs($variables): \Illuminate\Support\Collection
/**
* Generate Docker environment flags from environment variables collection
*
- * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
+ * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline'
* @return string Space-separated environment flags
*/
function generateDockerEnvFlags($variables): string
diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php
index 4a61960fb..0ec76f6fa 100644
--- a/bootstrap/helpers/github.php
+++ b/bootstrap/helpers/github.php
@@ -4,6 +4,7 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
+use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Lcobucci\JWT\Encoding\ChainedFormatter;
@@ -20,7 +21,7 @@ function generateGithubToken(GithubApp $source, string $type)
$timeDiff = abs($serverTime->diffInSeconds($githubTime));
if ($timeDiff > 50) {
- throw new \Exception(
+ throw new Exception(
'System time is out of sync with GitHub API time:
'.
'- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC
'.
'- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC
'.
@@ -60,7 +61,7 @@ function generateGithubToken(GithubApp $source, string $type)
return $response->json()['token'];
})(),
- default => throw new \InvalidArgumentException("Unsupported token type: {$type}")
+ default => throw new InvalidArgumentException("Unsupported token type: {$type}")
};
}
@@ -77,11 +78,11 @@ function generateGithubJwt(GithubApp $source)
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
{
if (is_null($source)) {
- throw new \Exception('Source is required for API calls');
+ throw new Exception('Source is required for API calls');
}
if ($source->getMorphClass() !== GithubApp::class) {
- throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
+ throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
}
if ($source->is_public) {
@@ -100,7 +101,7 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
$errorMessage = data_get($response->json(), 'message', 'no error message found');
$remainingCalls = $response->header('X-RateLimit-Remaining', '0');
- throw new \Exception(
+ throw new Exception(
'GitHub API call failed:
'.
"Error: {$errorMessage}
".
'Rate Limit Status:
'.
@@ -116,13 +117,19 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
];
}
-function getInstallationPath(GithubApp $source)
+function getInstallationPath(GithubApp $source): string
{
- $github = GithubApp::where('uuid', $source->uuid)->first();
- $name = str(Str::kebab($github->name));
- $installation_path = $github->html_url === 'https://github.com' ? 'apps' : 'github-apps';
+ $name = str(Str::kebab($source->name));
+ $installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
+ $state = Str::random(64);
- return "$github->html_url/$installation_path/$name/installations/new";
+ Cache::put('github-app-setup-state:'.hash('sha256', $state), [
+ 'action' => 'install',
+ 'github_app_id' => $source->id,
+ 'team_id' => $source->team_id,
+ ], now()->addMinutes(60));
+
+ return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]);
}
function getPermissionsPath(GithubApp $source)
diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php
index ed18dfe76..699704393 100644
--- a/bootstrap/helpers/proxy.php
+++ b/bootstrap/helpers/proxy.php
@@ -4,6 +4,7 @@ use App\Actions\Proxy\SaveProxyConfiguration;
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\Server;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Yaml\Yaml;
@@ -110,6 +111,7 @@ function connectProxyToNetworks(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
+
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --driver overlay --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
@@ -119,6 +121,7 @@ function connectProxyToNetworks(Server $server)
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
+
return [
"docker network ls --format '{{.Name}}' | grep '^{$network}$' >/dev/null || docker network create --attachable {$safe} >/dev/null",
"docker network connect {$safe} coolify-proxy >/dev/null 2>&1 || true",
@@ -135,7 +138,7 @@ function connectProxyToNetworks(Server $server)
* This must be called BEFORE docker compose up since the compose file declares networks as external.
*
* @param Server $server The server to ensure networks on
- * @return \Illuminate\Support\Collection Commands to create networks if they don't exist
+ * @return Collection Commands to create networks if they don't exist
*/
function ensureProxyNetworksExist(Server $server)
{
@@ -144,6 +147,7 @@ function ensureProxyNetworksExist(Server $server)
if ($server->isSwarm()) {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
+
return [
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --driver overlay --attachable {$safe}",
@@ -152,6 +156,7 @@ function ensureProxyNetworksExist(Server $server)
} else {
$commands = $networks->map(function ($network) {
$safe = escapeshellarg($network);
+
return [
"echo 'Ensuring network {$safe} exists...'",
"docker network ls --format '{{.Name}}' | grep -q '^{$network}$' || docker network create --attachable {$safe}",
@@ -211,7 +216,7 @@ function extractCustomProxyCommands(Server $server, string $existing_config): ar
$custom_commands[] = $command;
}
}
- } catch (\Exception $e) {
+ } catch (Exception $e) {
// If we can't parse the config, return empty array
// Silently fail to avoid breaking the proxy regeneration
}
@@ -432,7 +437,7 @@ function getExactTraefikVersionFromContainer(Server $server): ?string
Log::debug("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Could not detect exact version");
return null;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::error("getExactTraefikVersionFromContainer: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
return null;
@@ -479,7 +484,7 @@ function getTraefikVersionFromDockerCompose(Server $server): ?string
Log::debug("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Image format doesn't match expected pattern: {$image}");
return null;
- } catch (\Exception $e) {
+ } catch (Exception $e) {
Log::error("getTraefikVersionFromDockerCompose: Server '{$server->name}' (ID: {$server->id}) - Error: ".$e->getMessage());
return null;
diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php
index 2544719fc..3a516378f 100644
--- a/bootstrap/helpers/remoteProcess.php
+++ b/bootstrap/helpers/remoteProcess.php
@@ -200,6 +200,7 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
}
$application = Application::find(data_get($application_deployment_queue, 'application_id'));
$is_debug_enabled = data_get($application, 'settings.is_debug_enabled');
+ $serverTimezone = getServerTimezone(data_get($application, 'destination.server'));
$logs = data_get($application_deployment_queue, 'logs');
if (empty($logs)) {
@@ -240,8 +241,14 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d
return $formatted
->sortBy(fn ($i) => data_get($i, 'order'))
- ->map(function ($i) {
- data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u'));
+ ->map(function ($i) use ($serverTimezone) {
+ $timestamp = Carbon::parse(data_get($i, 'timestamp'));
+ try {
+ $timestamp->setTimezone($serverTimezone);
+ } catch (Exception) {
+ $timestamp->setTimezone('UTC');
+ }
+ data_set($i, 'timestamp', $timestamp->format('Y-M-d H:i:s.u'));
return $i;
})
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 011c149c9..08af8ee42 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -18,6 +18,7 @@ use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@@ -25,6 +26,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
@@ -155,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.
*
@@ -259,6 +328,16 @@ function currentTeam()
return Auth::user()?->currentTeam() ?? null;
}
+function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
+{
+ if (blank($uuid) || ! currentTeam()) {
+ return null;
+ }
+
+ return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first()
+ ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
+}
+
function showBoarding(): bool
{
if (isDev()) {
@@ -274,14 +353,30 @@ function showBoarding(): bool
function refreshSession(?Team $team = null): void
{
if (! $team) {
- if (Auth::user()->currentTeam()) {
- $team = Team::find(Auth::user()->currentTeam()->id);
- } else {
- $team = User::find(Auth::id())->teams->first();
+ $currentTeam = Auth::user()->currentTeam();
+ if ($currentTeam) {
+ // currentTeam() can resolve a stale (just-deleted) team from the
+ // session/cache, so Team::find() may still return null here.
+ $team = Team::find($currentTeam->id);
+ }
+ if (! $team) {
+ // Fall back to any team the user still belongs to.
+ $team = User::query()->find(Auth::id())?->teams()->first();
}
}
+
// Clear old cache key format for backwards compatibility
Cache::forget('team:'.Auth::id());
+
+ if (! $team) {
+ // The user has no team left (e.g. just deleted their current team and
+ // belongs to no other): clear the stale session reference instead of
+ // dereferencing null.
+ session()->forget('currentTeam');
+
+ return;
+ }
+
// Use new cache key format that includes team ID
Cache::forget('user:'.Auth::id().':team:'.$team->id);
Cache::remember('user:'.Auth::id().':team:'.$team->id, 3600, function () use ($team) {
@@ -513,6 +608,39 @@ function isCloud(): bool
return ! config('constants.coolify.self_hosted');
}
+/**
+ * Resolve the queue used for application deployments, database starts and service starts.
+ *
+ * On cloud these jobs run on a dedicated `deployments` queue so they can be drained by an
+ * isolated Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing
+ * is decided by `isCloud()` (config-based) rather than `HORIZON_QUEUES`, so the dispatching
+ * process needs no special env — only the worker must be configured to drain `deployments`.
+ *
+ * IMPORTANT: on cloud a worker MUST include `deployments` in its `HORIZON_QUEUES`, otherwise
+ * these jobs are never processed.
+ */
+function deployment_queue(): string
+{
+ return isCloud() ? 'deployments' : 'high';
+}
+
+/**
+ * Resolve the queue used for scheduled jobs — the scheduler dispatcher, scheduled tasks and
+ * scheduled database backups, whether triggered automatically or manually.
+ *
+ * On cloud these jobs run on a dedicated `crons` queue so they can be drained by an isolated
+ * Horizon worker pool; self-hosted keeps them on the shared `high` queue. Routing is decided
+ * by `isCloud()` (config-based), so the dispatching process needs no special env — only the
+ * worker must be configured to drain `crons`.
+ *
+ * IMPORTANT: on cloud a worker MUST include `crons` in its `HORIZON_QUEUES`, otherwise these
+ * jobs are never processed.
+ */
+function crons_queue(): string
+{
+ return isCloud() ? 'crons' : 'high';
+}
+
function translate_cron_expression($expression_to_validate): string
{
if (isset(VALID_CRON_STRINGS[$expression_to_validate])) {
@@ -979,44 +1107,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;
@@ -1025,37 +1126,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
@@ -1064,7 +1139,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
- return $resource;
+ return null;
}
function generateTagDeployWebhook($tag_name)
{
@@ -1374,23 +1449,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);
@@ -3453,10 +3528,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';
}
}
@@ -3489,34 +3564,6 @@ function getHelperVersion(): string
return config('constants.coolify.helper_version');
}
-function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
-{
- $server = Server::find($server_id)->where('team_id', $team_id)->first();
- if (! $server) {
- return;
- }
- $uuid = new Cuid2;
- $cloneCommand = "git clone --no-checkout -b $branch $repository .";
- $workdir = rtrim($base_directory, '/');
- $fileList = collect([".$workdir/coolify.json"]);
- $commands = collect([
- "rm -rf /tmp/{$uuid}",
- "mkdir -p /tmp/{$uuid}",
- "cd /tmp/{$uuid}",
- $cloneCommand,
- 'git sparse-checkout init --cone',
- "git sparse-checkout set {$fileList->implode(' ')}",
- 'git read-tree -mu HEAD',
- "cat .$workdir/coolify.json",
- 'rm -rf /tmp/{$uuid}',
- ]);
- try {
- return instant_remote_process($commands, $server);
- } catch (Exception) {
- // continue
- }
-}
-
function loggy($message = null, array $context = [])
{
if (! isDev()) {
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..7d958a9cc 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",
@@ -62,16 +62,16 @@
},
{
"name": "aws/aws-sdk-php",
- "version": "3.374.2",
+ "version": "3.381.5",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
- "reference": "67b6b6210af47319c74c5666388d71bc1bc58276"
+ "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/67b6b6210af47319c74c5666388d71bc1bc58276",
- "reference": "67b6b6210af47319c74c5666388d71bc1bc58276",
+ "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/409208d62af0ddafbcb0af1a0bf514f5ffcaba92",
+ "reference": "409208d62af0ddafbcb0af1a0bf514f5ffcaba92",
"shasum": ""
},
"require": {
@@ -153,22 +153,22 @@
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
- "source": "https://github.com/aws/aws-sdk-php/tree/3.374.2"
+ "source": "https://github.com/aws/aws-sdk-php/tree/3.381.5"
},
- "time": "2026-03-27T18:05:55+00:00"
+ "time": "2026-05-20T18:16:01+00:00"
},
{
"name": "bacon/bacon-qr-code",
- "version": "v3.0.4",
+ "version": "v3.1.1",
"source": {
"type": "git",
"url": "https://github.com/Bacon/BaconQrCode.git",
- "reference": "3feed0e212b8412cc5d2612706744789b0615824"
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/3feed0e212b8412cc5d2612706744789b0615824",
- "reference": "3feed0e212b8412cc5d2612706744789b0615824",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
+ "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2",
"shasum": ""
},
"require": {
@@ -208,9 +208,9 @@
"homepage": "https://github.com/Bacon/BaconQrCode",
"support": {
"issues": "https://github.com/Bacon/BaconQrCode/issues",
- "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.4"
+ "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1"
},
- "time": "2026-03-16T01:01:30+00:00"
+ "time": "2026-04-05T21:06:35+00:00"
},
{
"name": "brick/math",
@@ -1035,16 +1035,16 @@
},
{
"name": "firebase/php-jwt",
- "version": "v7.0.3",
+ "version": "v7.0.5",
"source": {
"type": "git",
- "url": "https://github.com/firebase/php-jwt.git",
- "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e"
+ "url": "https://github.com/googleapis/php-jwt.git",
+ "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
- "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e",
+ "url": "https://api.github.com/repos/googleapis/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380",
+ "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380",
"shasum": ""
},
"require": {
@@ -1052,6 +1052,7 @@
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
+ "phpfastcache/phpfastcache": "^9.2",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
@@ -1091,10 +1092,10 @@
"php"
],
"support": {
- "issues": "https://github.com/firebase/php-jwt/issues",
- "source": "https://github.com/firebase/php-jwt/tree/v7.0.3"
+ "issues": "https://github.com/googleapis/php-jwt/issues",
+ "source": "https://github.com/googleapis/php-jwt/tree/v7.0.5"
},
- "time": "2026-02-25T22:16:40+00:00"
+ "time": "2026-04-01T20:38:03+00:00"
},
{
"name": "fruitcake/php-cors",
@@ -1231,16 +1232,16 @@
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.10.0",
+ "version": "7.10.3",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+ "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
- "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
+ "reference": "47ba23c7a55247e2e1b7407aca90e9bbed0d9d86",
"shasum": ""
},
"require": {
@@ -1258,8 +1259,9 @@
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
+ "guzzlehttp/test-server": "^0.3.2",
"php-http/message-factory": "^1.1",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
},
"suggest": {
@@ -1337,7 +1339,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.3"
},
"funding": [
{
@@ -1353,20 +1355,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-23T22:36:01+00:00"
+ "time": "2026-05-20T22:59:19+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.3.0",
+ "version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+ "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
- "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/09e8a212562fb1fb6a512c4156ed71525969d6c2",
+ "reference": "09e8a212562fb1fb6a512c4156ed71525969d6c2",
"shasum": ""
},
"require": {
@@ -1374,7 +1376,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"type": "library",
"extra": {
@@ -1420,7 +1422,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.3.0"
+ "source": "https://github.com/guzzle/promises/tree/2.4.1"
},
"funding": [
{
@@ -1436,20 +1438,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-22T14:34:08+00:00"
+ "time": "2026-05-20T22:57:30+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.9.0",
+ "version": "2.10.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
+ "reference": "73ab136360b5dfd858006eae9795e8fe43c80361"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
- "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/73ab136360b5dfd858006eae9795e8fe43c80361",
+ "reference": "73ab136360b5dfd858006eae9795e8fe43c80361",
"shasum": ""
},
"require": {
@@ -1464,9 +1466,9 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "http-interop/http-factory-tests": "0.9.0",
+ "http-interop/http-factory-tests": "1.1.0",
"jshttp/mime-db": "1.54.0.1",
- "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+ "phpunit/phpunit": "^8.5.52 || ^9.6.34"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -1537,7 +1539,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+ "source": "https://github.com/guzzle/psr7/tree/2.10.1"
},
"funding": [
{
@@ -1553,7 +1555,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-10T16:41:02+00:00"
+ "time": "2026-05-20T09:27:36+00:00"
},
{
"name": "guzzlehttp/uri-template",
@@ -1703,28 +1705,29 @@
},
{
"name": "laravel/fortify",
- "version": "v1.36.2",
+ "version": "v1.37.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
- "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9"
+ "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/fortify/zipball/b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
- "reference": "b36e0782e6f5f6cfbab34327895a63b7c4c031f9",
+ "url": "https://api.github.com/repos/laravel/fortify/zipball/5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c",
+ "reference": "5d4b6a53527edd19ecc4f13e8e74ec91bdefab0c",
"shasum": ""
},
"require": {
"bacon/bacon-qr-code": "^3.0",
"ext-json": "*",
- "illuminate/console": "^10.0|^11.0|^12.0|^13.0",
- "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
- "php": "^8.1",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "laravel/passkeys": "^0.2.0",
+ "php": "^8.2",
"pragmarx/google2fa": "^9.0"
},
"require-dev": {
- "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
@@ -1762,20 +1765,20 @@
"issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify"
},
- "time": "2026-03-20T20:13:51+00:00"
+ "time": "2026-05-15T22:59:10+00:00"
},
{
"name": "laravel/framework",
- "version": "v12.55.1",
+ "version": "v12.60.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33"
+ "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/6d9185a248d101b07eecaf8fd60b18129545fd33",
- "reference": "6d9185a248d101b07eecaf8fd60b18129545fd33",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
+ "reference": "b8b55ce32175cc00f834a56eeb6316f18ed6ea39",
"shasum": ""
},
"require": {
@@ -1816,8 +1819,8 @@
"symfony/mailer": "^7.2.0",
"symfony/mime": "^7.2.0",
"symfony/polyfill-php83": "^1.33",
- "symfony/polyfill-php84": "^1.33",
- "symfony/polyfill-php85": "^1.33",
+ "symfony/polyfill-php84": "^1.34",
+ "symfony/polyfill-php85": "^1.34",
"symfony/process": "^7.2.0",
"symfony/routing": "^7.2.0",
"symfony/uid": "^7.2.0",
@@ -1984,20 +1987,20 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2026-03-18T14:28:59+00:00"
+ "time": "2026-05-20T11:48:19+00:00"
},
{
"name": "laravel/horizon",
- "version": "v5.45.4",
+ "version": "v5.47.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/horizon.git",
- "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6"
+ "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6",
- "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6",
+ "url": "https://api.github.com/repos/laravel/horizon/zipball/be74bc494f7a244d74f1c8ad6552f9b8621f10c6",
+ "reference": "be74bc494f7a244d74f1c8ad6552f9b8621f10c6",
"shasum": ""
},
"require": {
@@ -2062,22 +2065,95 @@
],
"support": {
"issues": "https://github.com/laravel/horizon/issues",
- "source": "https://github.com/laravel/horizon/tree/v5.45.4"
+ "source": "https://github.com/laravel/horizon/tree/v5.47.0"
},
- "time": "2026-03-18T14:14:59+00:00"
+ "time": "2026-05-19T20:54:47+00:00"
},
{
- "name": "laravel/nightwatch",
- "version": "v1.24.4",
+ "name": "laravel/mcp",
+ "version": "v0.6.7",
"source": {
"type": "git",
- "url": "https://github.com/laravel/nightwatch.git",
- "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8"
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/nightwatch/zipball/127e9bb9928f0fcf69b52b244053b393c90347c8",
- "reference": "127e9bb9928f0fcf69b52b244053b393c90347c8",
+ "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.27.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/nightwatch.git",
+ "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/nightwatch/zipball/d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31",
+ "reference": "d0f9cbe7364ffb9e4577c558a8fe7cded4d07a31",
"shasum": ""
},
"require": {
@@ -2106,9 +2182,9 @@
"livewire/livewire": "^2.0|^3.0",
"mockery/mockery": "^1.0",
"mongodb/laravel-mongodb": "^4.0|^5.0",
- "orchestra/testbench": "^8.0|^9.0|^10.0",
- "orchestra/testbench-core": "^8.0|^9.0|^10.0",
- "orchestra/workbench": "^8.0|^9.0|^10.0",
+ "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0",
+ "orchestra/testbench-core": "^8.0|^9.0|^10.0|^11.0",
+ "orchestra/workbench": "^8.0|^9.0|^10.0|^11.0",
"phpstan/phpstan": "^1.0",
"phpunit/phpunit": "^10.0|^11.0|^12.0",
"singlestoredb/singlestoredb-laravel": "^1.0|^2.0",
@@ -2158,7 +2234,7 @@
"issues": "https://github.com/laravel/nightwatch/issues",
"source": "https://github.com/laravel/nightwatch"
},
- "time": "2026-03-18T23:25:05+00:00"
+ "time": "2026-05-21T01:59:31+00:00"
},
{
"name": "laravel/pail",
@@ -2241,17 +2317,85 @@
"time": "2026-02-09T13:44:54+00:00"
},
{
- "name": "laravel/prompts",
- "version": "v0.3.16",
+ "name": "laravel/passkeys",
+ "version": "v0.2.1",
"source": {
"type": "git",
- "url": "https://github.com/laravel/prompts.git",
- "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2"
+ "url": "https://github.com/laravel/passkeys-server.git",
+ "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2",
- "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2",
+ "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c",
+ "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/http": "^11.0|^12.0|^13.0",
+ "illuminate/routing": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "php": "^8.2",
+ "web-auth/webauthn-lib": "5.3.x"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.28.0",
+ "orchestra/testbench": "^9.0|^10.0|^11.0",
+ "pestphp/pest": "^3.0|^4.0",
+ "phpstan/phpstan": "^2.0",
+ "rector/rector": "^2.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Passkeys\\PasskeysServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Passkeys\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Passwordless authentication using WebAuthn/passkeys for Laravel",
+ "homepage": "https://github.com/laravel/passkeys-server",
+ "keywords": [
+ "Authentication",
+ "Passwordless",
+ "laravel",
+ "passkeys",
+ "webauthn"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/passkeys-server/issues",
+ "source": "https://github.com/laravel/passkeys-server"
+ },
+ "time": "2026-05-18T16:26:00+00:00"
+ },
+ {
+ "name": "laravel/prompts",
+ "version": "v0.3.18",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/prompts.git",
+ "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72",
+ "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72",
"shasum": ""
},
"require": {
@@ -2295,22 +2439,22 @@
"description": "Add beautiful and user-friendly forms to your command-line applications.",
"support": {
"issues": "https://github.com/laravel/prompts/issues",
- "source": "https://github.com/laravel/prompts/tree/v0.3.16"
+ "source": "https://github.com/laravel/prompts/tree/v0.3.18"
},
- "time": "2026-03-23T14:35:33+00:00"
+ "time": "2026-05-19T00:47:18+00:00"
},
{
"name": "laravel/sanctum",
- "version": "v4.3.1",
+ "version": "v4.3.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
- "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76"
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
- "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
"shasum": ""
},
"require": {
@@ -2360,20 +2504,20 @@
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
- "time": "2026-02-07T17:19:31+00:00"
+ "time": "2026-04-30T11:46:25+00:00"
},
{
"name": "laravel/sentinel",
- "version": "v1.0.1",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sentinel.git",
- "reference": "7a98db53e0d9d6f61387f3141c07477f97425603"
+ "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603",
- "reference": "7a98db53e0d9d6f61387f3141c07477f97425603",
+ "url": "https://api.github.com/repos/laravel/sentinel/zipball/972d9885d9d14312a118e9565c4e6ecc5e751ea1",
+ "reference": "972d9885d9d14312a118e9565c4e6ecc5e751ea1",
"shasum": ""
},
"require": {
@@ -2392,9 +2536,6 @@
"providers": [
"Laravel\\Sentinel\\SentinelServiceProvider"
]
- },
- "branch-alias": {
- "dev-main": "1.x-dev"
}
},
"autoload": {
@@ -2417,22 +2558,22 @@
}
],
"support": {
- "source": "https://github.com/laravel/sentinel/tree/v1.0.1"
+ "source": "https://github.com/laravel/sentinel/tree/v1.1.0"
},
- "time": "2026-02-12T13:32:54+00:00"
+ "time": "2026-03-24T14:03:38+00:00"
},
{
"name": "laravel/serializable-closure",
- "version": "v2.0.10",
+ "version": "v2.0.13",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669"
+ "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669",
- "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b566ee0dd251f3c4078bed003a7ce015f5ea6dce",
+ "reference": "b566ee0dd251f3c4078bed003a7ce015f5ea6dce",
"shasum": ""
},
"require": {
@@ -2480,20 +2621,20 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2026-02-20T19:59:49+00:00"
+ "time": "2026-04-16T14:03:50+00:00"
},
{
"name": "laravel/socialite",
- "version": "v5.26.0",
+ "version": "v5.27.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/socialite.git",
- "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0"
+ "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/socialite/zipball/1d26f0c653a5f0e88859f4197830a29fe0cc59d0",
- "reference": "1d26f0c653a5f0e88859f4197830a29fe0cc59d0",
+ "url": "https://api.github.com/repos/laravel/socialite/zipball/40e0757a75637c7b2dff05d3286b0d8fc25e5c0e",
+ "reference": "40e0757a75637c7b2dff05d3286b0d8fc25e5c0e",
"shasum": ""
},
"require": {
@@ -2552,7 +2693,7 @@
"issues": "https://github.com/laravel/socialite/issues",
"source": "https://github.com/laravel/socialite"
},
- "time": "2026-03-24T18:37:47+00:00"
+ "time": "2026-04-24T14:05:47+00:00"
},
{
"name": "laravel/tinker",
@@ -2947,16 +3088,16 @@
},
{
"name": "league/flysystem",
- "version": "3.33.0",
+ "version": "3.34.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem.git",
- "reference": "570b8871e0ce693764434b29154c54b434905350"
+ "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350",
- "reference": "570b8871e0ce693764434b29154c54b434905350",
+ "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e",
+ "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e",
"shasum": ""
},
"require": {
@@ -3024,26 +3165,26 @@
],
"support": {
"issues": "https://github.com/thephpleague/flysystem/issues",
- "source": "https://github.com/thephpleague/flysystem/tree/3.33.0"
+ "source": "https://github.com/thephpleague/flysystem/tree/3.34.0"
},
- "time": "2026-03-25T07:59:30+00:00"
+ "time": "2026-05-14T10:28:08+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
- "version": "3.32.0",
+ "version": "3.34.0",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
- "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0"
+ "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/a1979df7c9784d334ea6df356aed3d18ac6673d0",
- "reference": "a1979df7c9784d334ea6df356aed3d18ac6673d0",
+ "url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/0c62fdac907791d8649ad3c61cb7a77628344fb8",
+ "reference": "0c62fdac907791d8649ad3c61cb7a77628344fb8",
"shasum": ""
},
"require": {
- "aws/aws-sdk-php": "^3.295.10",
+ "aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
@@ -3079,9 +3220,9 @@
"storage"
],
"support": {
- "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.32.0"
+ "source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.34.0"
},
- "time": "2026-02-25T16:46:44+00:00"
+ "time": "2026-05-04T08:24:00+00:00"
},
{
"name": "league/flysystem-local",
@@ -3497,16 +3638,16 @@
},
{
"name": "livewire/livewire",
- "version": "v3.7.11",
+ "version": "v3.8.0",
"source": {
"type": "git",
"url": "https://github.com/livewire/livewire.git",
- "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6"
+ "reference": "d81d269243c3f18d302663c0ce5672990df08ca1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/livewire/livewire/zipball/addd6e8e9234df75f29e6a327ee2a745a7d67bb6",
- "reference": "addd6e8e9234df75f29e6a327ee2a745a7d67bb6",
+ "url": "https://api.github.com/repos/livewire/livewire/zipball/d81d269243c3f18d302663c0ce5672990df08ca1",
+ "reference": "d81d269243c3f18d302663c0ce5672990df08ca1",
"shasum": ""
},
"require": {
@@ -3561,7 +3702,7 @@
"description": "A front-end framework for Laravel.",
"support": {
"issues": "https://github.com/livewire/livewire/issues",
- "source": "https://github.com/livewire/livewire/tree/v3.7.11"
+ "source": "https://github.com/livewire/livewire/tree/v3.8.0"
},
"funding": [
{
@@ -3569,7 +3710,7 @@
"type": "github"
}
],
- "time": "2026-02-26T00:58:19+00:00"
+ "time": "2026-04-30T23:56:43+00:00"
},
{
"name": "log1x/laravel-webfonts",
@@ -3952,16 +4093,16 @@
},
{
"name": "nesbot/carbon",
- "version": "3.11.3",
+ "version": "3.11.4",
"source": {
"type": "git",
"url": "https://github.com/CarbonPHP/carbon.git",
- "reference": "6a7e652845bb018c668220c2a545aded8594fbbf"
+ "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6a7e652845bb018c668220c2a545aded8594fbbf",
- "reference": "6a7e652845bb018c668220c2a545aded8594fbbf",
+ "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60",
+ "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60",
"shasum": ""
},
"require": {
@@ -4053,7 +4194,7 @@
"type": "tidelift"
}
],
- "time": "2026-03-11T17:23:39+00:00"
+ "time": "2026-04-07T09:57:54+00:00"
},
{
"name": "nette/schema",
@@ -4124,16 +4265,16 @@
},
{
"name": "nette/utils",
- "version": "v4.1.3",
+ "version": "v4.1.4",
"source": {
"type": "git",
"url": "https://github.com/nette/utils.git",
- "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe"
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe",
- "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe",
+ "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
+ "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
"shasum": ""
},
"require": {
@@ -4209,9 +4350,9 @@
],
"support": {
"issues": "https://github.com/nette/utils/issues",
- "source": "https://github.com/nette/utils/tree/v4.1.3"
+ "source": "https://github.com/nette/utils/tree/v4.1.4"
},
- "time": "2026-02-13T03:05:33+00:00"
+ "time": "2026-05-11T20:49:54+00:00"
},
{
"name": "nikic/php-parser",
@@ -4608,102 +4749,6 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
- {
- "name": "paragonie/sodium_compat",
- "version": "v2.5.0",
- "source": {
- "type": "git",
- "url": "https://github.com/paragonie/sodium_compat.git",
- "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/4714da6efdc782c06690bc72ce34fae7941c2d9f",
- "reference": "4714da6efdc782c06690bc72ce34fae7941c2d9f",
- "shasum": ""
- },
- "require": {
- "php": "^8.1",
- "php-64bit": "*"
- },
- "require-dev": {
- "infection/infection": "^0",
- "nikic/php-fuzzer": "^0",
- "phpunit/phpunit": "^7|^8|^9|^10|^11",
- "vimeo/psalm": "^4|^5|^6"
- },
- "suggest": {
- "ext-sodium": "Better performance, password hashing (Argon2i), secure memory management (memzero), and better security."
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "2.0.x-dev"
- }
- },
- "autoload": {
- "files": [
- "autoload.php"
- ],
- "psr-4": {
- "ParagonIE\\Sodium\\": "namespaced/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "ISC"
- ],
- "authors": [
- {
- "name": "Paragon Initiative Enterprises",
- "email": "security@paragonie.com"
- },
- {
- "name": "Frank Denis",
- "email": "jedisct1@pureftpd.org"
- }
- ],
- "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists",
- "keywords": [
- "Authentication",
- "BLAKE2b",
- "ChaCha20",
- "ChaCha20-Poly1305",
- "Chapoly",
- "Curve25519",
- "Ed25519",
- "EdDSA",
- "Edwards-curve Digital Signature Algorithm",
- "Elliptic Curve Diffie-Hellman",
- "Poly1305",
- "Pure-PHP cryptography",
- "RFC 7748",
- "RFC 8032",
- "Salpoly",
- "Salsa20",
- "X25519",
- "XChaCha20-Poly1305",
- "XSalsa20-Poly1305",
- "Xchacha20",
- "Xsalsa20",
- "aead",
- "cryptography",
- "ecdh",
- "elliptic curve",
- "elliptic curve cryptography",
- "encryption",
- "libsodium",
- "php",
- "public-key cryptography",
- "secret-key cryptography",
- "side-channel resistant"
- ],
- "support": {
- "issues": "https://github.com/paragonie/sodium_compat/issues",
- "source": "https://github.com/paragonie/sodium_compat/tree/v2.5.0"
- },
- "time": "2025-12-30T16:12:18+00:00"
- },
{
"name": "php-di/invoker",
"version": "2.3.7",
@@ -4832,78 +4877,6 @@
],
"time": "2025-08-16T11:10:48+00:00"
},
- {
- "name": "phpdocumentor/reflection",
- "version": "6.4.4",
- "source": {
- "type": "git",
- "url": "https://github.com/phpDocumentor/Reflection.git",
- "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c",
- "reference": "5e5db15b34e6eae755cb97beaa7fe076ae9e8d4c",
- "shasum": ""
- },
- "require": {
- "composer-runtime-api": "^2",
- "nikic/php-parser": "~4.18 || ^5.0",
- "php": "8.1.*|8.2.*|8.3.*|8.4.*|8.5.*",
- "phpdocumentor/reflection-common": "^2.1",
- "phpdocumentor/reflection-docblock": "^5",
- "phpdocumentor/type-resolver": "^1.4",
- "symfony/polyfill-php80": "^1.28",
- "webmozart/assert": "^1.7"
- },
- "require-dev": {
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
- "doctrine/coding-standard": "^13.0",
- "eliashaeussler/phpunit-attributes": "^1.8",
- "mikey179/vfsstream": "~1.2",
- "mockery/mockery": "~1.6.0",
- "phpspec/prophecy-phpunit": "^2.4",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-webmozart-assert": "^1.2",
- "phpunit/phpunit": "^10.5.53",
- "psalm/phar": "^6.0",
- "rector/rector": "^1.0.0",
- "squizlabs/php_codesniffer": "^3.8"
- },
- "type": "library",
- "extra": {
- "branch-alias": {
- "dev-5.x": "5.3.x-dev",
- "dev-6.x": "6.0.x-dev"
- }
- },
- "autoload": {
- "files": [
- "src/php-parser/Modifiers.php"
- ],
- "psr-4": {
- "phpDocumentor\\": "src/phpDocumentor"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "description": "Reflection library to do Static Analysis for PHP Projects",
- "homepage": "http://www.phpdoc.org",
- "keywords": [
- "phpDocumentor",
- "phpdoc",
- "reflection",
- "static analysis"
- ],
- "support": {
- "issues": "https://github.com/phpDocumentor/Reflection/issues",
- "source": "https://github.com/phpDocumentor/Reflection/tree/6.4.4"
- },
- "time": "2025-11-25T21:21:18+00:00"
- },
{
"name": "phpdocumentor/reflection-common",
"version": "2.2.0",
@@ -4959,16 +4932,16 @@
},
{
"name": "phpdocumentor/reflection-docblock",
- "version": "5.6.7",
+ "version": "6.0.3",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
- "reference": "31a105931bc8ffa3a123383829772e832fd8d903"
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903",
- "reference": "31a105931bc8ffa3a123383829772e832fd8d903",
+ "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582",
+ "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582",
"shasum": ""
},
"require": {
@@ -4976,8 +4949,8 @@
"ext-filter": "*",
"php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.2",
- "phpdocumentor/type-resolver": "^1.7",
- "phpstan/phpdoc-parser": "^1.7|^2.0",
+ "phpdocumentor/type-resolver": "^2.0",
+ "phpstan/phpdoc-parser": "^2.0",
"webmozart/assert": "^1.9.1 || ^2"
},
"require-dev": {
@@ -4987,7 +4960,8 @@
"phpstan/phpstan-mockery": "^1.1",
"phpstan/phpstan-webmozart-assert": "^1.2",
"phpunit/phpunit": "^9.5",
- "psalm/phar": "^5.26"
+ "psalm/phar": "^5.26",
+ "shipmonk/dead-code-detector": "^0.5.1"
},
"type": "library",
"extra": {
@@ -5017,44 +4991,44 @@
"description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.",
"support": {
"issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues",
- "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7"
+ "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3"
},
- "time": "2026-03-18T20:47:46+00:00"
+ "time": "2026-03-18T20:49:53+00:00"
},
{
"name": "phpdocumentor/type-resolver",
- "version": "1.12.0",
+ "version": "2.0.0",
"source": {
"type": "git",
"url": "https://github.com/phpDocumentor/TypeResolver.git",
- "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195"
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195",
- "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195",
+ "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9",
+ "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.0",
- "php": "^7.3 || ^8.0",
+ "php": "^7.4 || ^8.0",
"phpdocumentor/reflection-common": "^2.0",
- "phpstan/phpdoc-parser": "^1.18|^2.0"
+ "phpstan/phpdoc-parser": "^2.0"
},
"require-dev": {
"ext-tokenizer": "*",
"phpbench/phpbench": "^1.2",
- "phpstan/extension-installer": "^1.1",
- "phpstan/phpstan": "^1.8",
- "phpstan/phpstan-phpunit": "^1.1",
+ "phpstan/extension-installer": "^1.4",
+ "phpstan/phpstan": "^2.1",
+ "phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
- "rector/rector": "^0.13.9",
- "vimeo/psalm": "^4.25"
+ "psalm/phar": "^4"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-1.x": "1.x-dev"
+ "dev-1.x": "1.x-dev",
+ "dev-2.x": "2.x-dev"
}
},
"autoload": {
@@ -5075,9 +5049,9 @@
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
"support": {
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
- "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0"
+ "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0"
},
- "time": "2025-11-21T15:09:14+00:00"
+ "time": "2026-01-06T21:53:42+00:00"
},
{
"name": "phpoption/phpoption",
@@ -5156,16 +5130,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.51",
+ "version": "3.0.52",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748"
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/d59c94077f9c9915abb51ddb52ce85188ece1748",
- "reference": "d59c94077f9c9915abb51ddb52ce85188ece1748",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
+ "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
"shasum": ""
},
"require": {
@@ -5246,7 +5220,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.51"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
},
"funding": [
{
@@ -5262,7 +5236,7 @@
"type": "tidelift"
}
],
- "time": "2026-04-10T01:33:53+00:00"
+ "time": "2026-04-27T07:02:15+00:00"
},
{
"name": "phpstan/phpdoc-parser",
@@ -6063,23 +6037,22 @@
},
{
"name": "pusher/pusher-php-server",
- "version": "7.2.7",
+ "version": "7.2.8",
"source": {
"type": "git",
"url": "https://github.com/pusher/pusher-http-php.git",
- "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7"
+ "reference": "4aa139ed2a2a805cd265449b691198beee1309d2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/148b0b5100d000ed57195acdf548a2b1b38ee3f7",
- "reference": "148b0b5100d000ed57195acdf548a2b1b38ee3f7",
+ "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/4aa139ed2a2a805cd265449b691198beee1309d2",
+ "reference": "4aa139ed2a2a805cd265449b691198beee1309d2",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"guzzlehttp/guzzle": "^7.2",
- "paragonie/sodium_compat": "^1.6|^2.0",
"php": "^7.3|^8.0",
"psr/log": "^1.0|^2.0|^3.0"
},
@@ -6118,9 +6091,9 @@
],
"support": {
"issues": "https://github.com/pusher/pusher-http-php/issues",
- "source": "https://github.com/pusher/pusher-http-php/tree/7.2.7"
+ "source": "https://github.com/pusher/pusher-http-php/tree/7.2.8"
},
- "time": "2025-01-06T10:56:20+00:00"
+ "time": "2026-05-18T13:11:36+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -6448,16 +6421,16 @@
},
{
"name": "sentry/sentry",
- "version": "4.23.0",
+ "version": "4.27.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
- "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66"
+ "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/121a674d5fffcdb8e414b75c1b76edba8e592b66",
- "reference": "121a674d5fffcdb8e414b75c1b76edba8e592b66",
+ "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/1f0544cff8443ac1d25d6521487118e28381a1c2",
+ "reference": "1f0544cff8443ac1d25d6521487118e28381a1c2",
"shasum": ""
},
"require": {
@@ -6474,6 +6447,7 @@
"raven/raven": "*"
},
"require-dev": {
+ "carthage-software/mago": "^1.13.3",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/promises": "^2.0.3",
"guzzlehttp/psr7": "^1.8.4|^2.1.1",
@@ -6489,6 +6463,7 @@
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
+ "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
@@ -6525,7 +6500,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
- "source": "https://github.com/getsentry/sentry-php/tree/4.23.0"
+ "source": "https://github.com/getsentry/sentry-php/tree/4.27.0"
},
"funding": [
{
@@ -6537,20 +6512,20 @@
"type": "custom"
}
],
- "time": "2026-03-23T13:15:52+00:00"
+ "time": "2026-05-06T14:32:16+00:00"
},
{
"name": "sentry/sentry-laravel",
- "version": "4.24.0",
+ "version": "4.25.1",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
- "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d"
+ "reference": "67efbdd74a752fcc1038676986b055a4df7d5084"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
- "reference": "f823bd85e38e06cb4f1b7a82d48a2fc95320b31d",
+ "url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/67efbdd74a752fcc1038676986b055a4df7d5084",
+ "reference": "67efbdd74a752fcc1038676986b055a4df7d5084",
"shasum": ""
},
"require": {
@@ -6616,7 +6591,7 @@
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
- "source": "https://github.com/getsentry/sentry-laravel/tree/4.24.0"
+ "source": "https://github.com/getsentry/sentry-laravel/tree/4.25.1"
},
"funding": [
{
@@ -6628,7 +6603,7 @@
"type": "custom"
}
],
- "time": "2026-03-24T10:33:54+00:00"
+ "time": "2026-05-05T09:22:46+00:00"
},
{
"name": "socialiteproviders/authentik",
@@ -7264,29 +7239,31 @@
},
{
"name": "spatie/laravel-data",
- "version": "4.20.1",
+ "version": "4.23.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-data.git",
- "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad"
+ "reference": "230543769c996e407fec2873930626aed7dd0d3b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-data/zipball/5490cb15de6fc8b35a8cd2f661fac072d987a1ad",
- "reference": "5490cb15de6fc8b35a8cd2f661fac072d987a1ad",
+ "url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b",
+ "reference": "230543769c996e407fec2873930626aed7dd0d3b",
"shasum": ""
},
"require": {
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"php": "^8.1",
- "phpdocumentor/reflection": "^6.0",
+ "phpdocumentor/reflection-common": "^2.2",
+ "phpdocumentor/reflection-docblock": "^5.3 || ^6.0",
+ "phpdocumentor/type-resolver": "^1.7 || ^2.0",
"spatie/laravel-package-tools": "^1.9.0",
"spatie/php-structure-discoverer": "^2.0"
},
"require-dev": {
"fakerphp/faker": "^1.14",
"friendsofphp/php-cs-fixer": "^3.0",
- "inertiajs/inertia-laravel": "^2.0",
+ "inertiajs/inertia-laravel": "^2.0|^3.0",
"livewire/livewire": "^3.0|^4.0",
"mockery/mockery": "^1.6",
"nesbot/carbon": "^2.63|^3.0",
@@ -7334,7 +7311,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-data/issues",
- "source": "https://github.com/spatie/laravel-data/tree/4.20.1"
+ "source": "https://github.com/spatie/laravel-data/tree/4.23.0"
},
"funding": [
{
@@ -7342,7 +7319,7 @@
"type": "github"
}
],
- "time": "2026-03-18T07:44:01+00:00"
+ "time": "2026-05-08T14:41:13+00:00"
},
{
"name": "spatie/laravel-markdown",
@@ -7422,16 +7399,16 @@
},
{
"name": "spatie/laravel-package-tools",
- "version": "1.93.0",
+ "version": "1.93.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-package-tools.git",
- "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7"
+ "reference": "d5552849801f2642aea710557463234b59ef65eb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/0d097bce95b2bf6802fb1d83e1e753b0f5a948e7",
- "reference": "0d097bce95b2bf6802fb1d83e1e753b0f5a948e7",
+ "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d5552849801f2642aea710557463234b59ef65eb",
+ "reference": "d5552849801f2642aea710557463234b59ef65eb",
"shasum": ""
},
"require": {
@@ -7471,7 +7448,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-package-tools/issues",
- "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.0"
+ "source": "https://github.com/spatie/laravel-package-tools/tree/1.93.1"
},
"funding": [
{
@@ -7479,20 +7456,20 @@
"type": "github"
}
],
- "time": "2026-02-21T12:49:54+00:00"
+ "time": "2026-05-19T14:06:37+00:00"
},
{
"name": "spatie/laravel-ray",
- "version": "1.43.7",
+ "version": "1.43.9",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ray.git",
- "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3"
+ "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/d550d0b5bf87bb1b1668089f3c843e786ee522d3",
- "reference": "d550d0b5bf87bb1b1668089f3c843e786ee522d3",
+ "url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
+ "reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
"shasum": ""
},
"require": {
@@ -7511,7 +7488,7 @@
"require-dev": {
"guzzlehttp/guzzle": "^7.3",
"laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
- "laravel/pint": "^1.27",
+ "laravel/pint": "^1.29",
"orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"pestphp/pest": "^1.22|^2.0|^3.0|^4.0",
"phpstan/phpstan": "^1.10.57|^2.0.2",
@@ -7556,7 +7533,7 @@
],
"support": {
"issues": "https://github.com/spatie/laravel-ray/issues",
- "source": "https://github.com/spatie/laravel-ray/tree/1.43.7"
+ "source": "https://github.com/spatie/laravel-ray/tree/1.43.9"
},
"funding": [
{
@@ -7568,7 +7545,7 @@
"type": "other"
}
],
- "time": "2026-03-06T08:19:04+00:00"
+ "time": "2026-04-28T06:07:04+00:00"
},
{
"name": "spatie/laravel-schemaless-attributes",
@@ -7699,16 +7676,16 @@
},
{
"name": "spatie/php-structure-discoverer",
- "version": "2.4.0",
+ "version": "2.4.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/php-structure-discoverer.git",
- "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146"
+ "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/9a53c79b48fca8b6d15faa8cbba47cc430355146",
- "reference": "9a53c79b48fca8b6d15faa8cbba47cc430355146",
+ "url": "https://api.github.com/repos/spatie/php-structure-discoverer/zipball/10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
+ "reference": "10cd4e0018450d23e2bd8f8472569ad0c445c0fc",
"shasum": ""
},
"require": {
@@ -7766,7 +7743,7 @@
],
"support": {
"issues": "https://github.com/spatie/php-structure-discoverer/issues",
- "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.0"
+ "source": "https://github.com/spatie/php-structure-discoverer/tree/2.4.2"
},
"funding": [
{
@@ -7774,20 +7751,20 @@
"type": "github"
}
],
- "time": "2026-02-21T15:57:15+00:00"
+ "time": "2026-04-28T06:26:02+00:00"
},
{
"name": "spatie/ray",
- "version": "1.47.0",
+ "version": "1.48.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ray.git",
- "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce"
+ "reference": "974ac9c6e315033ab8ace883d60e094522f88ede"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/ray/zipball/3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce",
- "reference": "3112acb6a7fbcefe35f6e47b1dc13341ff5bc5ce",
+ "url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede",
+ "reference": "974ac9c6e315033ab8ace883d60e094522f88ede",
"shasum": ""
},
"require": {
@@ -7847,7 +7824,7 @@
],
"support": {
"issues": "https://github.com/spatie/ray/issues",
- "source": "https://github.com/spatie/ray/tree/1.47.0"
+ "source": "https://github.com/spatie/ray/tree/1.48.0"
},
"funding": [
{
@@ -7859,20 +7836,20 @@
"type": "other"
}
],
- "time": "2026-02-20T20:42:26+00:00"
+ "time": "2026-03-31T12:44:31+00:00"
},
{
"name": "spatie/shiki-php",
- "version": "2.3.3",
+ "version": "2.4.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/shiki-php.git",
- "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b"
+ "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/shiki-php/zipball/9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b",
- "reference": "9d50ff4d9825d87d3283a6695c65ae9c3c3caa6b",
+ "url": "https://api.github.com/repos/spatie/shiki-php/zipball/b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba",
+ "reference": "b8b0ca32d3a82bc5c533e68ffab96c5d4ec1b9ba",
"shasum": ""
},
"require": {
@@ -7916,7 +7893,7 @@
"spatie"
],
"support": {
- "source": "https://github.com/spatie/shiki-php/tree/2.3.3"
+ "source": "https://github.com/spatie/shiki-php/tree/2.4.0"
},
"funding": [
{
@@ -7924,7 +7901,7 @@
"type": "github"
}
],
- "time": "2026-02-01T09:30:04+00:00"
+ "time": "2026-04-27T14:27:52+00:00"
},
{
"name": "spatie/url",
@@ -7988,6 +7965,187 @@
],
"time": "2024-03-08T11:35:19+00:00"
},
+ {
+ "name": "spomky-labs/cbor-php",
+ "version": "3.2.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/cbor-php.git",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "reference": "dd6eb84e6d92f7b8bd0da56b4b4dd7235aed0c32",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.0"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "roave/security-advisories": "dev-latest",
+ "symfony/error-handler": "^6.4|^7.1|^8.0",
+ "symfony/var-dumper": "^6.4|^7.1|^8.0"
+ },
+ "suggest": {
+ "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags",
+ "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "CBOR\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors"
+ }
+ ],
+ "description": "CBOR Encoder/Decoder for PHP",
+ "keywords": [
+ "Concise Binary Object Representation",
+ "RFC7049",
+ "cbor"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/cbor-php/issues",
+ "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.2.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-04-01T12:15:20+00:00"
+ },
+ {
+ "name": "spomky-labs/pki-framework",
+ "version": "1.4.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Spomky-Labs/pki-framework.git",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "reference": "aa576cbd07128075bef97ac2f8af9854e67513d8",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-mbstring": "*",
+ "php": ">=8.1",
+ "psr/clock": "^1.0"
+ },
+ "require-dev": {
+ "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0",
+ "ext-gmp": "*",
+ "ext-openssl": "*",
+ "infection/infection": "^0.28|^0.29|^0.31|^0.32",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpstan/extension-installer": "^1.3|^2.0",
+ "phpstan/phpstan": "^1.8|^2.0",
+ "phpstan/phpstan-deprecation-rules": "^1.0|^2.0",
+ "phpstan/phpstan-phpunit": "^1.1|^2.0",
+ "phpstan/phpstan-strict-rules": "^1.3|^2.0",
+ "phpunit/phpunit": "^10.1|^11.0|^12.0|^13.0",
+ "rector/rector": "^1.0|^2.0",
+ "roave/security-advisories": "dev-latest",
+ "symfony/string": "^6.4|^7.0|^8.0",
+ "symfony/var-dumper": "^6.4|^7.0|^8.0",
+ "symplify/easy-coding-standard": "^12.0|^13.0"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance (or GMP)",
+ "ext-gmp": "For better performance (or BCMath)",
+ "ext-openssl": "For OpenSSL based cyphering"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "SpomkyLabs\\Pki\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Joni Eskelinen",
+ "email": "jonieske@gmail.com",
+ "role": "Original developer"
+ },
+ {
+ "name": "Florent Morselli",
+ "email": "florent.morselli@spomky-labs.com",
+ "role": "Spomky-Labs PKI Framework developer"
+ }
+ ],
+ "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.",
+ "homepage": "https://github.com/spomky-labs/pki-framework",
+ "keywords": [
+ "DER",
+ "Private Key",
+ "ac",
+ "algorithm identifier",
+ "asn.1",
+ "asn1",
+ "attribute certificate",
+ "certificate",
+ "certification request",
+ "cryptography",
+ "csr",
+ "decrypt",
+ "ec",
+ "encrypt",
+ "pem",
+ "pkcs",
+ "public key",
+ "rsa",
+ "sign",
+ "signature",
+ "verify",
+ "x.509",
+ "x.690",
+ "x509",
+ "x690"
+ ],
+ "support": {
+ "issues": "https://github.com/Spomky-Labs/pki-framework/issues",
+ "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.4.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-03-23T22:56:56+00:00"
+ },
{
"name": "stevebauman/purify",
"version": "v6.3.2",
@@ -8115,16 +8273,16 @@
},
{
"name": "symfony/clock",
- "version": "v8.0.0",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/clock.git",
- "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f"
+ "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f",
- "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f",
+ "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3",
+ "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3",
"shasum": ""
},
"require": {
@@ -8168,7 +8326,7 @@
"time"
],
"support": {
- "source": "https://github.com/symfony/clock/tree/v8.0.0"
+ "source": "https://github.com/symfony/clock/tree/v8.0.8"
},
"funding": [
{
@@ -8188,20 +8346,20 @@
"type": "tidelift"
}
],
- "time": "2025-11-12T15:46:48+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/console",
- "version": "v7.4.7",
+ "version": "v7.4.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
- "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d"
+ "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/console/zipball/e1e6770440fb9c9b0cf725f81d1361ad1835329d",
- "reference": "e1e6770440fb9c9b0cf725f81d1361ad1835329d",
+ "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075",
+ "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075",
"shasum": ""
},
"require": {
@@ -8266,7 +8424,7 @@
"terminal"
],
"support": {
- "source": "https://github.com/symfony/console/tree/v7.4.7"
+ "source": "https://github.com/symfony/console/tree/v7.4.11"
},
"funding": [
{
@@ -8286,20 +8444,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-06T14:06:20+00:00"
+ "time": "2026-05-13T12:04:42+00:00"
},
{
"name": "symfony/css-selector",
- "version": "v8.0.6",
+ "version": "v8.0.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/css-selector.git",
- "reference": "2a178bf80f05dbbe469a337730eba79d61315262"
+ "reference": "3665cfade90565430909b906394c73c8739e57d0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262",
- "reference": "2a178bf80f05dbbe469a337730eba79d61315262",
+ "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0",
+ "reference": "3665cfade90565430909b906394c73c8739e57d0",
"shasum": ""
},
"require": {
@@ -8335,7 +8493,7 @@
"description": "Converts CSS selectors to XPath expressions",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/css-selector/tree/v8.0.6"
+ "source": "https://github.com/symfony/css-selector/tree/v8.0.9"
},
"funding": [
{
@@ -8355,20 +8513,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-17T13:07:04+00:00"
+ "time": "2026-04-18T13:51:42+00:00"
},
{
"name": "symfony/deprecation-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
- "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b",
+ "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b",
"shasum": ""
},
"require": {
@@ -8381,7 +8539,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -8406,7 +8564,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -8417,25 +8575,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-04-13T15:52:40+00:00"
},
{
"name": "symfony/error-handler",
- "version": "v7.4.4",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
- "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8"
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8",
- "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8",
+ "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
+ "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa",
"shasum": ""
},
"require": {
@@ -8484,7 +8646,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/error-handler/tree/v7.4.4"
+ "source": "https://github.com/symfony/error-handler/tree/v7.4.8"
},
"funding": [
{
@@ -8504,20 +8666,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-20T16:42:42+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/event-dispatcher",
- "version": "v8.0.4",
+ "version": "v8.0.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
- "reference": "99301401da182b6cfaa4700dbe9987bb75474b47"
+ "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47",
- "reference": "99301401da182b6cfaa4700dbe9987bb75474b47",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f",
+ "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f",
"shasum": ""
},
"require": {
@@ -8569,7 +8731,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4"
+ "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9"
},
"funding": [
{
@@ -8589,20 +8751,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-05T11:45:55+00:00"
+ "time": "2026-04-18T13:51:42+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
- "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+ "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32",
+ "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32",
"shasum": ""
},
"require": {
@@ -8616,7 +8778,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -8649,7 +8811,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -8660,25 +8822,29 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2024-09-25T14:21:43+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
"name": "symfony/filesystem",
- "version": "v8.0.6",
+ "version": "v8.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
- "reference": "7bf9162d7a0dff98d079b72948508fa48018a770"
+ "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770",
- "reference": "7bf9162d7a0dff98d079b72948508fa48018a770",
+ "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7",
+ "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7",
"shasum": ""
},
"require": {
@@ -8715,7 +8881,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/filesystem/tree/v8.0.6"
+ "source": "https://github.com/symfony/filesystem/tree/v8.0.11"
},
"funding": [
{
@@ -8735,20 +8901,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-25T16:59:43+00:00"
+ "time": "2026-05-11T16:39:47+00:00"
},
{
"name": "symfony/finder",
- "version": "v7.4.6",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf"
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
- "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+ "reference": "e0be088d22278583a82da281886e8c3592fbf149",
"shasum": ""
},
"require": {
@@ -8783,7 +8949,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v7.4.6"
+ "source": "https://github.com/symfony/finder/tree/v7.4.8"
},
"funding": [
{
@@ -8803,20 +8969,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-29T09:40:50+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-foundation",
- "version": "v7.4.7",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
- "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81"
+ "reference": "9381209597ec66c25be154cbf2289076e64d1eab"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
- "reference": "f94b3e7b7dafd40e666f0c9ff2084133bae41e81",
+ "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab",
+ "reference": "9381209597ec66c25be154cbf2289076e64d1eab",
"shasum": ""
},
"require": {
@@ -8865,7 +9031,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-foundation/tree/v7.4.7"
+ "source": "https://github.com/symfony/http-foundation/tree/v7.4.8"
},
"funding": [
{
@@ -8885,20 +9051,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-06T13:15:18+00:00"
+ "time": "2026-03-24T13:12:05+00:00"
},
{
"name": "symfony/http-kernel",
- "version": "v7.4.7",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
- "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1"
+ "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-kernel/zipball/3b3fcf386c809be990c922e10e4c620d6367cab1",
- "reference": "3b3fcf386c809be990c922e10e4c620d6367cab1",
+ "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
+ "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d",
"shasum": ""
},
"require": {
@@ -8984,7 +9150,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/http-kernel/tree/v7.4.7"
+ "source": "https://github.com/symfony/http-kernel/tree/v7.4.12"
},
"funding": [
{
@@ -9004,20 +9170,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-06T16:33:18+00:00"
+ "time": "2026-05-20T09:27:11+00:00"
},
{
"name": "symfony/mailer",
- "version": "v7.4.6",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/mailer.git",
- "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9"
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9",
- "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9",
+ "url": "https://api.github.com/repos/symfony/mailer/zipball/5cefb712a25f320579615ba9e1942abaeade7dff",
+ "reference": "5cefb712a25f320579615ba9e1942abaeade7dff",
"shasum": ""
},
"require": {
@@ -9068,7 +9234,7 @@
"description": "Helps sending emails",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/mailer/tree/v7.4.6"
+ "source": "https://github.com/symfony/mailer/tree/v7.4.12"
},
"funding": [
{
@@ -9088,20 +9254,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-25T16:50:00+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
"name": "symfony/mime",
- "version": "v7.4.7",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/mime.git",
- "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1"
+ "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/mime/zipball/da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
- "reference": "da5ab4fde3f6c88ab06e96185b9922f48b677cd1",
+ "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470",
+ "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470",
"shasum": ""
},
"require": {
@@ -9157,7 +9323,7 @@
"mime-type"
],
"support": {
- "source": "https://github.com/symfony/mime/tree/v7.4.7"
+ "source": "https://github.com/symfony/mime/tree/v7.4.12"
},
"funding": [
{
@@ -9177,20 +9343,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T15:24:09+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
"name": "symfony/options-resolver",
- "version": "v8.0.0",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
- "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
+ "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
- "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
+ "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
+ "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
"shasum": ""
},
"require": {
@@ -9228,7 +9394,7 @@
"options"
],
"support": {
- "source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
+ "source": "https://github.com/symfony/options-resolver/tree/v8.0.8"
},
"funding": [
{
@@ -9248,20 +9414,20 @@
"type": "tidelift"
}
],
- "time": "2025-11-12T15:55:31+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638"
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638",
- "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638",
+ "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+ "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
"shasum": ""
},
"require": {
@@ -9311,7 +9477,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
},
"funding": [
{
@@ -9331,20 +9497,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-iconv",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-iconv.git",
- "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa"
+ "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa",
- "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa",
+ "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57",
+ "reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57",
"shasum": ""
},
"require": {
@@ -9395,7 +9561,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-iconv/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0"
},
"funding": [
{
@@ -9415,20 +9581,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-17T14:58:18+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70"
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70",
- "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+ "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
"shasum": ""
},
"require": {
@@ -9477,7 +9643,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
},
"funding": [
{
@@ -9497,20 +9663,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-27T09:58:17+00:00"
+ "time": "2026-04-26T13:13:48+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-idn.git",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
- "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412",
+ "reference": "dc21118016c039a66235cf93d96b435ffb282412",
"shasum": ""
},
"require": {
@@ -9564,7 +9730,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1"
},
"funding": [
{
@@ -9584,20 +9750,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-10T14:38:51+00:00"
+ "time": "2026-05-25T15:22:23+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.33.0",
+ "version": "v1.38.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
- "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+ "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b",
+ "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b",
"shasum": ""
},
"require": {
@@ -9649,7 +9815,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0"
},
"funding": [
{
@@ -9669,20 +9835,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-05-25T13:48:31+00:00"
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.33.0",
+ "version": "v1.38.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
- "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
+ "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92",
+ "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92",
"shasum": ""
},
"require": {
@@ -9734,7 +9900,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1"
},
"funding": [
{
@@ -9754,20 +9920,20 @@
"type": "tidelift"
}
],
- "time": "2024-12-23T08:48:59+00:00"
+ "time": "2026-05-26T12:51:13+00:00"
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
- "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
+ "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+ "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"shasum": ""
},
"require": {
@@ -9818,7 +9984,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
},
"funding": [
{
@@ -9838,20 +10004,20 @@
"type": "tidelift"
}
],
- "time": "2025-01-02T08:10:11+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/polyfill-php83",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php83.git",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5"
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5",
- "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5",
+ "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149",
+ "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149",
"shasum": ""
},
"require": {
@@ -9898,7 +10064,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0"
},
"funding": [
{
@@ -9918,20 +10084,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-08T02:45:35+00:00"
+ "time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php84",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php84.git",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191"
+ "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191",
- "reference": "d8ced4d875142b6a7426000426b8abc631d6b191",
+ "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06",
+ "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06",
"shasum": ""
},
"require": {
@@ -9978,7 +10144,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0"
},
"funding": [
{
@@ -9998,20 +10164,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-24T13:30:11+00:00"
+ "time": "2026-04-10T18:47:49+00:00"
},
{
"name": "symfony/polyfill-php85",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
- "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
+ "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee",
+ "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee",
"shasum": ""
},
"require": {
@@ -10058,7 +10224,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0"
},
"funding": [
{
@@ -10078,20 +10244,20 @@
"type": "tidelift"
}
],
- "time": "2025-06-23T16:12:55+00:00"
+ "time": "2026-04-26T13:10:57+00:00"
},
{
"name": "symfony/polyfill-uuid",
- "version": "v1.33.0",
+ "version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-uuid.git",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2"
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
- "reference": "21533be36c24be3f4b1669c4725c7d1d2bab4ae2",
+ "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
+ "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94",
"shasum": ""
},
"require": {
@@ -10141,7 +10307,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/polyfill-uuid/tree/v1.33.0"
+ "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0"
},
"funding": [
{
@@ -10161,20 +10327,20 @@
"type": "tidelift"
}
],
- "time": "2024-09-09T11:45:10+00:00"
+ "time": "2026-04-10T16:19:22+00:00"
},
{
"name": "symfony/process",
- "version": "v7.4.5",
+ "version": "v7.4.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
- "reference": "608476f4604102976d687c483ac63a79ba18cc97"
+ "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97",
- "reference": "608476f4604102976d687c483ac63a79ba18cc97",
+ "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0",
+ "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0",
"shasum": ""
},
"require": {
@@ -10206,7 +10372,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/process/tree/v7.4.5"
+ "source": "https://github.com/symfony/process/tree/v7.4.11"
},
"funding": [
{
@@ -10226,20 +10392,187 @@
"type": "tidelift"
}
],
- "time": "2026-01-26T15:07:59+00:00"
+ "time": "2026-05-11T16:55:21+00:00"
},
{
- "name": "symfony/psr-http-message-bridge",
- "version": "v8.0.4",
+ "name": "symfony/property-access",
+ "version": "v8.0.8",
"source": {
"type": "git",
- "url": "https://github.com/symfony/psr-http-message-bridge.git",
- "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531"
+ "url": "https://github.com/symfony/property-access.git",
+ "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531",
- "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531",
+ "url": "https://api.github.com/repos/symfony/property-access/zipball/704c7808116fcdd67327db7b17de56b8ef6169e4",
+ "reference": "704c7808116fcdd67327db7b17de56b8ef6169e4",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/property-info": "^7.4.4|^8.0.4"
+ },
+ "require-dev": {
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/var-exporter": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\PropertyAccess\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Provides functions to read and write from/to an object or array using a simple string notation",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "access",
+ "array",
+ "extraction",
+ "index",
+ "injection",
+ "object",
+ "property",
+ "property-path",
+ "reflection"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/property-access/tree/v8.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T15:14:47+00:00"
+ },
+ {
+ "name": "symfony/property-info",
+ "version": "v8.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/property-info.git",
+ "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/property-info/zipball/c21711980653360d6ef5c26d0f9ca6f58a1135c6",
+ "reference": "c21711980653360d6ef5c26d0f9ca6f58a1135c6",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/string": "^7.4|^8.0",
+ "symfony/type-info": "^7.4.7|^8.0.7"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "phpstan/phpdoc-parser": "^1.0|^2.0",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/serializer": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\PropertyInfo\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Kévin Dunglas",
+ "email": "dunglas@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Extracts information about PHP class' properties using metadata of popular sources",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "doctrine",
+ "phpdoc",
+ "property",
+ "symfony",
+ "type",
+ "validator"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/property-info/tree/v8.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-03-30T15:14:47+00:00"
+ },
+ {
+ "name": "symfony/psr-http-message-bridge",
+ "version": "v8.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/psr-http-message-bridge.git",
+ "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19",
+ "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19",
"shasum": ""
},
"require": {
@@ -10293,7 +10626,7 @@
"psr-7"
],
"support": {
- "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4"
+ "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8"
},
"funding": [
{
@@ -10313,20 +10646,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-03T23:40:55+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/routing",
- "version": "v7.4.6",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
- "reference": "238d749c56b804b31a9bf3e26519d93b65a60938"
+ "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938",
- "reference": "238d749c56b804b31a9bf3e26519d93b65a60938",
+ "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
+ "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204",
"shasum": ""
},
"require": {
@@ -10378,7 +10711,7 @@
"url"
],
"support": {
- "source": "https://github.com/symfony/routing/tree/v7.4.6"
+ "source": "https://github.com/symfony/routing/tree/v7.4.12"
},
"funding": [
{
@@ -10398,20 +10731,118 @@
"type": "tidelift"
}
],
- "time": "2026-02-25T16:50:00+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
- "name": "symfony/service-contracts",
- "version": "v3.6.1",
+ "name": "symfony/serializer",
+ "version": "v8.0.10",
"source": {
"type": "git",
- "url": "https://github.com/symfony/service-contracts.git",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+ "url": "https://github.com/symfony/serializer.git",
+ "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
- "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+ "url": "https://api.github.com/repos/symfony/serializer/zipball/72ed7e1475790714f07c3a59bd01fd32cd022fdf",
+ "reference": "72ed7e1475790714f07c3a59bd01fd32cd022fdf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "symfony/polyfill-ctype": "^1.8"
+ },
+ "conflict": {
+ "phpdocumentor/reflection-docblock": "<5.2|>=7",
+ "phpdocumentor/type-resolver": "<1.5.1",
+ "symfony/property-access": "<7.4.2|>=8.0,<8.0.2",
+ "symfony/property-info": "<7.4",
+ "symfony/type-info": "<7.4"
+ },
+ "require-dev": {
+ "phpdocumentor/reflection-docblock": "^5.2|^6.0",
+ "phpstan/phpdoc-parser": "^1.0|^2.0",
+ "seld/jsonlint": "^1.10",
+ "symfony/cache": "^7.4|^8.0",
+ "symfony/config": "^7.4|^8.0",
+ "symfony/console": "^7.4|^8.0",
+ "symfony/dependency-injection": "^7.4|^8.0",
+ "symfony/error-handler": "^7.4|^8.0",
+ "symfony/filesystem": "^7.4|^8.0",
+ "symfony/form": "^7.4|^8.0",
+ "symfony/http-foundation": "^7.4|^8.0",
+ "symfony/http-kernel": "^7.4|^8.0",
+ "symfony/messenger": "^7.4|^8.0",
+ "symfony/mime": "^7.4|^8.0",
+ "symfony/property-access": "^7.4.2|^8.0.2",
+ "symfony/property-info": "^7.4|^8.0",
+ "symfony/translation-contracts": "^2.5|^3",
+ "symfony/type-info": "^7.4|^8.0",
+ "symfony/uid": "^7.4|^8.0",
+ "symfony/validator": "^7.4|^8.0",
+ "symfony/var-dumper": "^7.4|^8.0",
+ "symfony/var-exporter": "^7.4|^8.0",
+ "symfony/yaml": "^7.4|^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\Serializer\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Fabien Potencier",
+ "email": "fabien@symfony.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/serializer/tree/v8.0.10"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-05-04T13:41:39+00:00"
+ },
+ {
+ "name": "symfony/service-contracts",
+ "version": "v3.7.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/service-contracts.git",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a",
+ "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a",
"shasum": ""
},
"require": {
@@ -10429,7 +10860,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -10465,7 +10896,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/service-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -10485,20 +10916,20 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T11:30:57+00:00"
+ "time": "2026-03-28T09:44:51+00:00"
},
{
"name": "symfony/stopwatch",
- "version": "v8.0.0",
+ "version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
- "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942"
+ "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942",
- "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942",
+ "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
+ "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
"shasum": ""
},
"require": {
@@ -10531,7 +10962,7 @@
"description": "Provides a way to profile code",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/stopwatch/tree/v8.0.0"
+ "source": "https://github.com/symfony/stopwatch/tree/v8.0.8"
},
"funding": [
{
@@ -10551,20 +10982,20 @@
"type": "tidelift"
}
],
- "time": "2025-08-04T07:36:47+00:00"
+ "time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/string",
- "version": "v8.0.6",
+ "version": "v8.0.11",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
- "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4"
+ "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4",
- "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4",
+ "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff",
+ "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff",
"shasum": ""
},
"require": {
@@ -10621,7 +11052,7 @@
"utf8"
],
"support": {
- "source": "https://github.com/symfony/string/tree/v8.0.6"
+ "source": "https://github.com/symfony/string/tree/v8.0.11"
},
"funding": [
{
@@ -10641,20 +11072,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-09T10:14:57+00:00"
+ "time": "2026-05-13T12:07:53+00:00"
},
{
"name": "symfony/translation",
- "version": "v8.0.6",
+ "version": "v8.0.10",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation.git",
- "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b"
+ "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b",
- "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b",
+ "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67",
+ "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67",
"shasum": ""
},
"require": {
@@ -10714,7 +11145,7 @@
"description": "Provides tools to internationalize your application",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/translation/tree/v8.0.6"
+ "source": "https://github.com/symfony/translation/tree/v8.0.10"
},
"funding": [
{
@@ -10734,20 +11165,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-17T13:07:04+00:00"
+ "time": "2026-05-06T11:30:54+00:00"
},
{
"name": "symfony/translation-contracts",
- "version": "v3.6.1",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
- "reference": "65a8bc82080447fae78373aa10f8d13b38338977"
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977",
- "reference": "65a8bc82080447fae78373aa10f8d13b38338977",
+ "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d",
+ "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d",
"shasum": ""
},
"require": {
@@ -10760,7 +11191,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -10796,7 +11227,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1"
+ "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -10816,20 +11247,102 @@
"type": "tidelift"
}
],
- "time": "2025-07-15T13:41:35+00:00"
+ "time": "2026-01-05T13:30:16+00:00"
},
{
- "name": "symfony/uid",
- "version": "v7.4.4",
+ "name": "symfony/type-info",
+ "version": "v8.0.9",
"source": {
"type": "git",
- "url": "https://github.com/symfony/uid.git",
- "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36"
+ "url": "https://github.com/symfony/type-info.git",
+ "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36",
- "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36",
+ "url": "https://api.github.com/repos/symfony/type-info/zipball/08723aceb8c3271e8cb3db8b2565728b0c88e866",
+ "reference": "08723aceb8c3271e8cb3db8b2565728b0c88e866",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.4",
+ "psr/container": "^1.1|^2.0"
+ },
+ "conflict": {
+ "phpstan/phpdoc-parser": "<1.30"
+ },
+ "require-dev": {
+ "phpstan/phpdoc-parser": "^1.30|^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Symfony\\Component\\TypeInfo\\": ""
+ },
+ "exclude-from-classmap": [
+ "/Tests/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Mathias Arlaud",
+ "email": "mathias.arlaud@gmail.com"
+ },
+ {
+ "name": "Baptiste LEDUC",
+ "email": "baptiste.leduc@gmail.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "Extracts PHP types information.",
+ "homepage": "https://symfony.com",
+ "keywords": [
+ "PHPStan",
+ "phpdoc",
+ "symfony",
+ "type"
+ ],
+ "support": {
+ "source": "https://github.com/symfony/type-info/tree/v8.0.9"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2026-04-29T15:02:55+00:00"
+ },
+ {
+ "name": "symfony/uid",
+ "version": "v7.4.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/uid.git",
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439",
+ "reference": "2676b524340abcfe4d6151ec698463cebafee439",
"shasum": ""
},
"require": {
@@ -10874,7 +11387,7 @@
"uuid"
],
"support": {
- "source": "https://github.com/symfony/uid/tree/v7.4.4"
+ "source": "https://github.com/symfony/uid/tree/v7.4.9"
},
"funding": [
{
@@ -10894,20 +11407,20 @@
"type": "tidelift"
}
],
- "time": "2026-01-03T23:30:35+00:00"
+ "time": "2026-04-30T15:19:22+00:00"
},
{
"name": "symfony/var-dumper",
- "version": "v7.4.6",
+ "version": "v7.4.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
- "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291"
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291",
- "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291",
+ "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
+ "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd",
"shasum": ""
},
"require": {
@@ -10961,7 +11474,7 @@
"dump"
],
"support": {
- "source": "https://github.com/symfony/var-dumper/tree/v7.4.6"
+ "source": "https://github.com/symfony/var-dumper/tree/v7.4.8"
},
"funding": [
{
@@ -10981,20 +11494,20 @@
"type": "tidelift"
}
],
- "time": "2026-02-15T10:53:20+00:00"
+ "time": "2026-03-30T13:44:50+00:00"
},
{
"name": "symfony/yaml",
- "version": "v7.4.6",
+ "version": "v7.4.12",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
- "reference": "58751048de17bae71c5aa0d13cb19d79bca26391"
+ "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/yaml/zipball/58751048de17bae71c5aa0d13cb19d79bca26391",
- "reference": "58751048de17bae71c5aa0d13cb19d79bca26391",
+ "url": "https://api.github.com/repos/symfony/yaml/zipball/8b6952b56ca6417f25f7a65758cadd0ce02edc51",
+ "reference": "8b6952b56ca6417f25f7a65758cadd0ce02edc51",
"shasum": ""
},
"require": {
@@ -11037,7 +11550,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/yaml/tree/v7.4.6"
+ "source": "https://github.com/symfony/yaml/tree/v7.4.12"
},
"funding": [
{
@@ -11057,7 +11570,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-09T09:33:46+00:00"
+ "time": "2026-05-20T07:20:23+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
@@ -11270,23 +11783,23 @@
},
{
"name": "voku/portable-ascii",
- "version": "2.0.3",
+ "version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/voku/portable-ascii.git",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d"
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
- "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d",
+ "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f",
+ "reference": "8e1051fe39379367aecf014f41744ce7539a856f",
"shasum": ""
},
"require": {
- "php": ">=7.0.0"
+ "php": ">=7.1.0"
},
"require-dev": {
- "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0"
+ "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5"
},
"suggest": {
"ext-intl": "Use Intl for transliterator_transliterate() support"
@@ -11316,7 +11829,7 @@
],
"support": {
"issues": "https://github.com/voku/portable-ascii/issues",
- "source": "https://github.com/voku/portable-ascii/tree/2.0.3"
+ "source": "https://github.com/voku/portable-ascii/tree/2.1.1"
},
"funding": [
{
@@ -11340,27 +11853,184 @@
"type": "tidelift"
}
],
- "time": "2024-11-21T01:49:47+00:00"
+ "time": "2026-04-26T05:33:54+00:00"
},
{
- "name": "webmozart/assert",
- "version": "1.12.1",
+ "name": "web-auth/cose-lib",
+ "version": "4.5.2",
"source": {
"type": "git",
- "url": "https://github.com/webmozarts/assert.git",
- "reference": "9be6926d8b485f55b9229203f962b51ed377ba68"
+ "url": "https://github.com/web-auth/cose-lib.git",
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68",
- "reference": "9be6926d8b485f55b9229203f962b51ed377ba68",
+ "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "reference": "5b38660f90070a8e45f3dbc9528ade3b608dd77d",
+ "shasum": ""
+ },
+ "require": {
+ "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17",
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "php": ">=8.1",
+ "spomky-labs/pki-framework": "^1.0"
+ },
+ "require-dev": {
+ "spomky-labs/cbor-php": "^3.2.2"
+ },
+ "suggest": {
+ "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension",
+ "spomky-labs/cbor-php": "For COSE Signature support"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Cose\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/cose/contributors"
+ }
+ ],
+ "description": "CBOR Object Signing and Encryption (COSE) For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "COSE",
+ "RFC8152"
+ ],
+ "support": {
+ "issues": "https://github.com/web-auth/cose-lib/issues",
+ "source": "https://github.com/web-auth/cose-lib/tree/4.5.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-05-03T09:49:50+00:00"
+ },
+ {
+ "name": "web-auth/webauthn-lib",
+ "version": "5.3.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/web-auth/webauthn-lib.git",
+ "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/e6f656d6c6b29fa305382fe6a0a3be8177d177df",
+ "reference": "e6f656d6c6b29fa305382fe6a0a3be8177d177df",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-openssl": "*",
+ "paragonie/constant_time_encoding": "^2.6|^3.0",
+ "php": ">=8.2",
+ "phpdocumentor/reflection-docblock": "^5.3|^6.0",
+ "psr/clock": "^1.0",
+ "psr/event-dispatcher": "^1.0",
+ "psr/log": "^1.0|^2.0|^3.0",
+ "spomky-labs/cbor-php": "^3.0",
+ "spomky-labs/pki-framework": "^1.0",
+ "symfony/clock": "^6.4|^7.0|^8.0",
+ "symfony/deprecation-contracts": "^3.2",
+ "symfony/property-access": "^6.4|^7.0|^8.0",
+ "symfony/property-info": "^6.4|^7.0|^8.0",
+ "symfony/serializer": "^6.4|^7.0|^8.0",
+ "symfony/uid": "^6.4|^7.0|^8.0",
+ "web-auth/cose-lib": "^4.2.3"
+ },
+ "suggest": {
+ "psr/log-implementation": "Recommended to receive logs from the library",
+ "symfony/event-dispatcher": "Recommended to use dispatched events",
+ "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources"
+ },
+ "type": "library",
+ "extra": {
+ "thanks": {
+ "url": "https://github.com/web-auth/webauthn-framework",
+ "name": "web-auth/webauthn-framework"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Webauthn\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Florent Morselli",
+ "homepage": "https://github.com/Spomky"
+ },
+ {
+ "name": "All contributors",
+ "homepage": "https://github.com/web-auth/webauthn-library/contributors"
+ }
+ ],
+ "description": "FIDO2/Webauthn Support For PHP",
+ "homepage": "https://github.com/web-auth",
+ "keywords": [
+ "FIDO2",
+ "fido",
+ "webauthn"
+ ],
+ "support": {
+ "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/Spomky",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/FlorentMorselli",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-05-17T19:04:30+00:00"
+ },
+ {
+ "name": "webmozart/assert",
+ "version": "2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/webmozarts/assert.git",
+ "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
+ "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-date": "*",
"ext-filter": "*",
- "php": "^7.2 || ^8.0"
+ "php": "^8.2"
},
"suggest": {
"ext-intl": "",
@@ -11369,8 +12039,12 @@
},
"type": "library",
"extra": {
+ "psalm": {
+ "pluginClass": "Webmozart\\Assert\\PsalmPlugin"
+ },
"branch-alias": {
- "dev-master": "1.10-dev"
+ "dev-master": "2.0-dev",
+ "dev-feature/2-0": "2.0-dev"
}
},
"autoload": {
@@ -11386,6 +12060,10 @@
{
"name": "Bernhard Schussek",
"email": "bschussek@gmail.com"
+ },
+ {
+ "name": "Woody Gilk",
+ "email": "woody.gilk@gmail.com"
}
],
"description": "Assertions to validate method input/output with nice error messages.",
@@ -11396,9 +12074,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/1.12.1"
+ "source": "https://github.com/webmozarts/assert/tree/2.4.0"
},
- "time": "2025-10-29T15:56:20+00:00"
+ "time": "2026-05-20T13:07:01+00:00"
},
{
"name": "yosymfony/parser-utils",
@@ -12126,16 +12804,16 @@
},
{
"name": "amphp/hpack",
- "version": "v3.2.1",
+ "version": "v3.2.2",
"source": {
"type": "git",
"url": "https://github.com/amphp/hpack.git",
- "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239"
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239",
- "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
"shasum": ""
},
"require": {
@@ -12144,7 +12822,7 @@
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"http2jp/hpack-test-case": "^1",
- "nikic/php-fuzzer": "^0.0.10",
+ "nikic/php-fuzzer": "^0.0.11",
"phpunit/phpunit": "^7 | ^8 | ^9"
},
"type": "library",
@@ -12188,7 +12866,7 @@
],
"support": {
"issues": "https://github.com/amphp/hpack/issues",
- "source": "https://github.com/amphp/hpack/tree/v3.2.1"
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2"
},
"funding": [
{
@@ -12196,7 +12874,7 @@
"type": "github"
}
],
- "time": "2024-03-21T19:00:16+00:00"
+ "time": "2026-05-03T19:28:59+00:00"
},
{
"name": "amphp/http",
@@ -12264,16 +12942,16 @@
},
{
"name": "amphp/http-client",
- "version": "v5.3.4",
+ "version": "v5.3.6",
"source": {
"type": "git",
"url": "https://github.com/amphp/http-client.git",
- "reference": "75ad21574fd632594a2dd914496647816d5106bc"
+ "reference": "ca155026acafa74a612d776a97202d53077fee86"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc",
- "reference": "75ad21574fd632594a2dd914496647816d5106bc",
+ "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86",
"shasum": ""
},
"require": {
@@ -12301,9 +12979,8 @@
"amphp/phpunit-util": "^3",
"ext-json": "*",
"kelunik/link-header-rfc5988": "^1",
- "laminas/laminas-diactoros": "^2.3",
"phpunit/phpunit": "^9",
- "psalm/phar": "~5.23"
+ "psalm/phar": "6.16.1"
},
"suggest": {
"amphp/file": "Required for file request bodies and HTTP archive logging",
@@ -12350,7 +13027,7 @@
],
"support": {
"issues": "https://github.com/amphp/http-client/issues",
- "source": "https://github.com/amphp/http-client/tree/v5.3.4"
+ "source": "https://github.com/amphp/http-client/tree/v5.3.6"
},
"funding": [
{
@@ -12358,20 +13035,20 @@
"type": "github"
}
],
- "time": "2025-08-16T20:41:23+00:00"
+ "time": "2026-05-15T23:29:38+00:00"
},
{
"name": "amphp/http-server",
- "version": "v3.4.4",
+ "version": "v3.4.5",
"source": {
"type": "git",
"url": "https://github.com/amphp/http-server.git",
- "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef"
+ "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef",
- "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef",
+ "url": "https://api.github.com/repos/amphp/http-server/zipball/ae0fd01e16aba336247852df0c3f8c649a31896d",
+ "reference": "ae0fd01e16aba336247852df0c3f8c649a31896d",
"shasum": ""
},
"require": {
@@ -12398,7 +13075,7 @@
"league/uri-components": "^7.1",
"monolog/monolog": "^3",
"phpunit/phpunit": "^9",
- "psalm/phar": "~5.23"
+ "psalm/phar": "6.16.1"
},
"suggest": {
"ext-zlib": "Allows GZip compression of response bodies"
@@ -12447,7 +13124,7 @@
],
"support": {
"issues": "https://github.com/amphp/http-server/issues",
- "source": "https://github.com/amphp/http-server/tree/v3.4.4"
+ "source": "https://github.com/amphp/http-server/tree/v3.4.5"
},
"funding": [
{
@@ -12455,7 +13132,7 @@
"type": "github"
}
],
- "time": "2026-02-08T18:16:29+00:00"
+ "time": "2026-05-01T03:55:07+00:00"
},
{
"name": "amphp/parser",
@@ -12521,16 +13198,16 @@
},
{
"name": "amphp/pipeline",
- "version": "v1.2.3",
+ "version": "v1.2.4",
"source": {
"type": "git",
"url": "https://github.com/amphp/pipeline.git",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367"
+ "reference": "a044733e080940d1483f56caff0c412ad6982776"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367",
- "reference": "7b52598c2e9105ebcddf247fc523161581930367",
+ "url": "https://api.github.com/repos/amphp/pipeline/zipball/a044733e080940d1483f56caff0c412ad6982776",
+ "reference": "a044733e080940d1483f56caff0c412ad6982776",
"shasum": ""
},
"require": {
@@ -12542,7 +13219,7 @@
"amphp/php-cs-fixer-config": "^2",
"amphp/phpunit-util": "^3",
"phpunit/phpunit": "^9",
- "psalm/phar": "^5.18"
+ "psalm/phar": "6.16.1"
},
"type": "library",
"autoload": {
@@ -12576,7 +13253,7 @@
],
"support": {
"issues": "https://github.com/amphp/pipeline/issues",
- "source": "https://github.com/amphp/pipeline/tree/v1.2.3"
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.4"
},
"funding": [
{
@@ -12584,7 +13261,7 @@
"type": "github"
}
],
- "time": "2025-03-16T16:33:53+00:00"
+ "time": "2026-05-06T05:37:57+00:00"
},
{
"name": "amphp/process",
@@ -12656,24 +13333,27 @@
},
{
"name": "amphp/serialization",
- "version": "v1.0.0",
+ "version": "v1.1.0",
"source": {
"type": "git",
"url": "https://github.com/amphp/serialization.git",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1"
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/serialization/zipball/693e77b2fb0b266c3c7d622317f881de44ae94a1",
- "reference": "693e77b2fb0b266c3c7d622317f881de44ae94a1",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
"shasum": ""
},
"require": {
- "php": ">=7.1"
+ "php": ">=7.4"
},
"require-dev": {
- "amphp/php-cs-fixer-config": "dev-master",
- "phpunit/phpunit": "^9 || ^8 || ^7"
+ "amphp/php-cs-fixer-config": "^2",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
},
"type": "library",
"autoload": {
@@ -12708,22 +13388,28 @@
],
"support": {
"issues": "https://github.com/amphp/serialization/issues",
- "source": "https://github.com/amphp/serialization/tree/master"
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0"
},
- "time": "2020-03-25T21:39:07+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-05T15:59:53+00:00"
},
{
"name": "amphp/socket",
- "version": "v2.3.1",
+ "version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/amphp/socket.git",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1"
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1",
- "reference": "58e0422221825b79681b72c50c47a930be7bf1e1",
+ "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314",
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314",
"shasum": ""
},
"require": {
@@ -12732,17 +13418,17 @@
"amphp/dns": "^2",
"ext-openssl": "*",
"kelunik/certificate": "^1.1",
- "league/uri": "^6.5 | ^7",
- "league/uri-interfaces": "^2.3 | ^7",
+ "league/uri": "^7",
+ "league/uri-interfaces": "^7",
"php": ">=8.1",
- "revolt/event-loop": "^1 || ^0.2"
+ "revolt/event-loop": "^1"
},
"require-dev": {
"amphp/php-cs-fixer-config": "^2",
"amphp/phpunit-util": "^3",
"amphp/process": "^2",
"phpunit/phpunit": "^9",
- "psalm/phar": "5.20"
+ "psalm/phar": "6.16.1"
},
"type": "library",
"autoload": {
@@ -12786,7 +13472,7 @@
],
"support": {
"issues": "https://github.com/amphp/socket/issues",
- "source": "https://github.com/amphp/socket/tree/v2.3.1"
+ "source": "https://github.com/amphp/socket/tree/v2.4.0"
},
"funding": [
{
@@ -12794,7 +13480,7 @@
"type": "github"
}
],
- "time": "2024-04-21T14:33:03+00:00"
+ "time": "2026-04-19T15:09:56+00:00"
},
{
"name": "amphp/sync",
@@ -13123,16 +13809,16 @@
},
{
"name": "brianium/paratest",
- "version": "v7.19.2",
+ "version": "v7.20.0",
"source": {
"type": "git",
"url": "https://github.com/paratestphp/paratest.git",
- "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9"
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paratestphp/paratest/zipball/66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
- "reference": "66e4f7910cecf67736bccf2b8bd53a2e3eb98bd9",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
+ "reference": "81c80677c9ec0ed4ef16b246167f11dec81a6e3d",
"shasum": ""
},
"require": {
@@ -13156,7 +13842,7 @@
"ext-pcntl": "*",
"ext-pcov": "*",
"ext-posix": "*",
- "phpstan/phpstan": "^2.1.40",
+ "phpstan/phpstan": "^2.1.44",
"phpstan/phpstan-deprecation-rules": "^2.0.4",
"phpstan/phpstan-phpunit": "^2.0.16",
"phpstan/phpstan-strict-rules": "^2.0.10",
@@ -13200,7 +13886,7 @@
],
"support": {
"issues": "https://github.com/paratestphp/paratest/issues",
- "source": "https://github.com/paratestphp/paratest/tree/v7.19.2"
+ "source": "https://github.com/paratestphp/paratest/tree/v7.20.0"
},
"funding": [
{
@@ -13212,7 +13898,152 @@
"type": "paypal"
}
],
- "time": "2026-03-09T14:33:17+00:00"
+ "time": "2026-03-29T15:46:14+00:00"
+ },
+ {
+ "name": "composer/pcre",
+ "version": "3.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/pcre.git",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0"
+ },
+ "conflict": {
+ "phpstan/phpstan": "<1.11.10"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.12 || ^2",
+ "phpstan/phpstan-strict-rules": "^1 || ^2",
+ "phpunit/phpunit": "^8 || ^9"
+ },
+ "type": "library",
+ "extra": {
+ "phpstan": {
+ "includes": [
+ "extension.neon"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Composer\\Pcre\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Jordi Boggiano",
+ "email": "j.boggiano@seld.be",
+ "homepage": "http://seld.be"
+ }
+ ],
+ "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+ "keywords": [
+ "PCRE",
+ "preg",
+ "regex",
+ "regular expression"
+ ],
+ "support": {
+ "issues": "https://github.com/composer/pcre/issues",
+ "source": "https://github.com/composer/pcre/tree/3.3.2"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-11-12T16:29:46+00:00"
+ },
+ {
+ "name": "composer/xdebug-handler",
+ "version": "3.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/composer/xdebug-handler.git",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+ "shasum": ""
+ },
+ "require": {
+ "composer/pcre": "^1 || ^2 || ^3",
+ "php": "^7.2.5 || ^8.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-strict-rules": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Composer\\XdebugHandler\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "John Stevenson",
+ "email": "john-stevenson@blueyonder.co.uk"
+ }
+ ],
+ "description": "Restarts a process without Xdebug.",
+ "keywords": [
+ "Xdebug",
+ "performance"
+ ],
+ "support": {
+ "irc": "ircs://irc.libera.chat:6697/composer",
+ "issues": "https://github.com/composer/xdebug-handler/issues",
+ "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://packagist.com",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/composer",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2024-05-06T16:37:16+00:00"
},
{
"name": "daverandom/libdns",
@@ -13260,16 +14091,16 @@
},
{
"name": "driftingly/rector-laravel",
- "version": "2.2.0",
+ "version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/driftingly/rector-laravel.git",
- "reference": "807840ceb09de6764cbfcce0719108d044a459a9"
+ "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/807840ceb09de6764cbfcce0719108d044a459a9",
- "reference": "807840ceb09de6764cbfcce0719108d044a459a9",
+ "url": "https://api.github.com/repos/driftingly/rector-laravel/zipball/3c1c13f335b3b4d1a1f944a8ea194020044871ed",
+ "reference": "3c1c13f335b3b4d1a1f944a8ea194020044871ed",
"shasum": ""
},
"require": {
@@ -13290,9 +14121,9 @@
"description": "Rector upgrades rules for Laravel Framework",
"support": {
"issues": "https://github.com/driftingly/rector-laravel/issues",
- "source": "https://github.com/driftingly/rector-laravel/tree/2.2.0"
+ "source": "https://github.com/driftingly/rector-laravel/tree/2.3.0"
},
- "time": "2026-03-19T17:24:38+00:00"
+ "time": "2026-04-08T10:52:44+00:00"
},
{
"name": "fakerphp/faker",
@@ -13600,16 +14431,16 @@
},
{
"name": "laravel/boost",
- "version": "v2.4.1",
+ "version": "v2.4.8",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506"
+ "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/f6241df9fd81a86d79a051851177d4ffe3e28506",
- "reference": "f6241df9fd81a86d79a051851177d4ffe3e28506",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/d11d720cf9537f8d236a11d973e99563a598ec9c",
+ "reference": "d11d720cf9537f8d236a11d973e99563a598ec9c",
"shasum": ""
},
"require": {
@@ -13618,7 +14449,7 @@
"illuminate/contracts": "^11.45.3|^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",
- "laravel/mcp": "^0.5.1|^0.6.0",
+ "laravel/mcp": "^0.5.1|^0.6.0|~0.7.0,<0.7.1",
"laravel/prompts": "^0.3.10",
"laravel/roster": "^0.5.0",
"php": "^8.2"
@@ -13662,20 +14493,20 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2026-03-25T16:37:40+00:00"
+ "time": "2026-05-19T20:09:50+00:00"
},
{
"name": "laravel/dusk",
- "version": "v8.5.0",
+ "version": "v8.6.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/dusk.git",
- "reference": "f9f75666bed46d1ebca13792447be6e753f4e790"
+ "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/dusk/zipball/f9f75666bed46d1ebca13792447be6e753f4e790",
- "reference": "f9f75666bed46d1ebca13792447be6e753f4e790",
+ "url": "https://api.github.com/repos/laravel/dusk/zipball/e7fd48762c6a82ad2cd311db07587aa2a97ce143",
+ "reference": "e7fd48762c6a82ad2cd311db07587aa2a97ce143",
"shasum": ""
},
"require": {
@@ -13734,95 +14565,22 @@
],
"support": {
"issues": "https://github.com/laravel/dusk/issues",
- "source": "https://github.com/laravel/dusk/tree/v8.5.0"
+ "source": "https://github.com/laravel/dusk/tree/v8.6.0"
},
- "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"
+ "time": "2026-04-15T14:50:40+00:00"
},
{
"name": "laravel/pint",
- "version": "v1.29.0",
+ "version": "v1.29.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/pint.git",
- "reference": "bdec963f53172c5e36330f3a400604c69bf02d39"
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39",
- "reference": "bdec963f53172c5e36330f3a400604c69bf02d39",
+ "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80",
+ "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80",
"shasum": ""
},
"require": {
@@ -13833,14 +14591,14 @@
"php": "^8.2.0"
},
"require-dev": {
- "friendsofphp/php-cs-fixer": "^3.94.2",
- "illuminate/view": "^12.54.1",
- "larastan/larastan": "^3.9.3",
- "laravel-zero/framework": "^12.0.5",
+ "friendsofphp/php-cs-fixer": "^3.95.1",
+ "illuminate/view": "^12.56.0",
+ "larastan/larastan": "^3.9.6",
+ "laravel-zero/framework": "^12.1.0",
"mockery/mockery": "^1.6.12",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest": "^3.8.6",
- "shipfastlabs/agent-detector": "^1.1.0"
+ "shipfastlabs/agent-detector": "^1.1.3"
},
"bin": [
"builds/pint"
@@ -13877,7 +14635,7 @@
"issues": "https://github.com/laravel/pint/issues",
"source": "https://github.com/laravel/pint"
},
- "time": "2026-03-12T15:51:39+00:00"
+ "time": "2026-04-20T15:26:14+00:00"
},
{
"name": "laravel/roster",
@@ -13942,16 +14700,16 @@
},
{
"name": "laravel/telescope",
- "version": "v5.19.0",
+ "version": "v5.20.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
- "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b"
+ "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b",
- "reference": "5e95df170d14e03dd74c4b744969cf01f67a050b",
+ "url": "https://api.github.com/repos/laravel/telescope/zipball/38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
+ "reference": "38ec6e6006a67e05e0c476c5f8ef3550b72e43d8",
"shasum": ""
},
"require": {
@@ -14005,9 +14763,9 @@
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
- "source": "https://github.com/laravel/telescope/tree/v5.19.0"
+ "source": "https://github.com/laravel/telescope/tree/v5.20.0"
},
- "time": "2026-03-24T18:37:14+00:00"
+ "time": "2026-04-06T12:52:26+00:00"
},
{
"name": "league/uri-components",
@@ -14238,23 +14996,23 @@
},
{
"name": "nunomaduro/collision",
- "version": "v8.9.1",
+ "version": "v8.9.4",
"source": {
"type": "git",
"url": "https://github.com/nunomaduro/collision.git",
- "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935"
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935",
- "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935",
+ "url": "https://api.github.com/repos/nunomaduro/collision/zipball/716af8f95a470e9094cfca09ed897b023be191a5",
+ "reference": "716af8f95a470e9094cfca09ed897b023be191a5",
"shasum": ""
},
"require": {
"filp/whoops": "^2.18.4",
"nunomaduro/termwind": "^2.4.0",
"php": "^8.2.0",
- "symfony/console": "^7.4.4 || ^8.0.4"
+ "symfony/console": "^7.4.8 || ^8.0.8"
},
"conflict": {
"laravel/framework": "<11.48.0 || >=14.0.0",
@@ -14262,12 +15020,12 @@
},
"require-dev": {
"brianium/paratest": "^7.8.5",
- "larastan/larastan": "^3.9.2",
- "laravel/framework": "^11.48.0 || ^12.52.0",
- "laravel/pint": "^1.27.1",
- "orchestra/testbench-core": "^9.12.0 || ^10.9.0",
- "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0",
- "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0"
+ "larastan/larastan": "^3.9.6",
+ "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.5.0",
+ "laravel/pint": "^1.29.1",
+ "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.2.1",
+ "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0",
+ "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.3.0"
},
"type": "library",
"extra": {
@@ -14330,45 +15088,47 @@
"type": "patreon"
}
],
- "time": "2026-02-17T17:33:08+00:00"
+ "time": "2026-04-21T14:04:20+00:00"
},
{
"name": "pestphp/pest",
- "version": "v4.4.3",
+ "version": "v4.7.0",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest.git",
- "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495"
+ "reference": "2fc75cfcf03c041c804778fa894282234adc3c66"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest/zipball/e6ab897594312728ef2e32d586cb4f6780b1b495",
- "reference": "e6ab897594312728ef2e32d586cb4f6780b1b495",
+ "url": "https://api.github.com/repos/pestphp/pest/zipball/2fc75cfcf03c041c804778fa894282234adc3c66",
+ "reference": "2fc75cfcf03c041c804778fa894282234adc3c66",
"shasum": ""
},
"require": {
- "brianium/paratest": "^7.19.2",
- "nunomaduro/collision": "^8.9.1",
+ "brianium/paratest": "^7.20.0",
+ "composer/xdebug-handler": "^3.0.5",
+ "nunomaduro/collision": "^8.9.4",
"nunomaduro/termwind": "^2.4.0",
"pestphp/pest-plugin": "^4.0.0",
- "pestphp/pest-plugin-arch": "^4.0.0",
+ "pestphp/pest-plugin-arch": "^4.0.2",
"pestphp/pest-plugin-mutate": "^4.0.1",
"pestphp/pest-plugin-profanity": "^4.2.1",
"php": "^8.3.0",
- "phpunit/phpunit": "^12.5.14",
- "symfony/process": "^7.4.5|^8.0.5"
+ "phpunit/phpunit": "^12.5.24",
+ "symfony/process": "^7.4.8|^8.0.8"
},
"conflict": {
"filp/whoops": "<2.18.3",
- "phpunit/phpunit": ">12.5.14",
+ "phpunit/phpunit": ">12.5.24",
"sebastian/exporter": "<7.0.0",
"webmozart/assert": "<1.11.0"
},
"require-dev": {
+ "mrpunyapal/peststan": "^0.2.9",
"pestphp/pest-dev-tools": "^4.1.0",
- "pestphp/pest-plugin-browser": "^4.3.0",
- "pestphp/pest-plugin-type-coverage": "^4.0.3",
- "psy/psysh": "^0.12.21"
+ "pestphp/pest-plugin-browser": "^4.3.1",
+ "pestphp/pest-plugin-type-coverage": "^4.0.4",
+ "psy/psysh": "^0.12.22"
},
"bin": [
"bin/pest"
@@ -14395,6 +15155,7 @@
"Pest\\Plugins\\Verbose",
"Pest\\Plugins\\Version",
"Pest\\Plugins\\Shard",
+ "Pest\\Plugins\\Tia",
"Pest\\Plugins\\Parallel"
]
},
@@ -14434,7 +15195,7 @@
],
"support": {
"issues": "https://github.com/pestphp/pest/issues",
- "source": "https://github.com/pestphp/pest/tree/v4.4.3"
+ "source": "https://github.com/pestphp/pest/tree/v4.7.0"
},
"funding": [
{
@@ -14446,7 +15207,7 @@
"type": "github"
}
],
- "time": "2026-03-21T13:14:39+00:00"
+ "time": "2026-05-03T16:09:32+00:00"
},
{
"name": "pestphp/pest-plugin",
@@ -14520,26 +15281,26 @@
},
{
"name": "pestphp/pest-plugin-arch",
- "version": "v4.0.0",
+ "version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest-plugin-arch.git",
- "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d"
+ "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/25bb17e37920ccc35cbbcda3b00d596aadf3e58d",
- "reference": "25bb17e37920ccc35cbbcda3b00d596aadf3e58d",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-arch/zipball/3fb0d02a91b9da504b139dc7ab2a31efb7c3215c",
+ "reference": "3fb0d02a91b9da504b139dc7ab2a31efb7c3215c",
"shasum": ""
},
"require": {
"pestphp/pest-plugin": "^4.0.0",
"php": "^8.3",
- "ta-tikoma/phpunit-architecture-test": "^0.8.5"
+ "ta-tikoma/phpunit-architecture-test": "^0.8.7"
},
"require-dev": {
- "pestphp/pest": "^4.0.0",
- "pestphp/pest-dev-tools": "^4.0.0"
+ "pestphp/pest": "^4.4.6",
+ "pestphp/pest-dev-tools": "^4.1.0"
},
"type": "library",
"extra": {
@@ -14574,7 +15335,7 @@
"unit"
],
"support": {
- "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.0"
+ "source": "https://github.com/pestphp/pest-plugin-arch/tree/v4.0.2"
},
"funding": [
{
@@ -14586,20 +15347,20 @@
"type": "github"
}
],
- "time": "2025-08-20T13:10:51+00:00"
+ "time": "2026-04-10T17:20:19+00:00"
},
{
"name": "pestphp/pest-plugin-browser",
- "version": "v4.3.0",
+ "version": "v4.3.1",
"source": {
"type": "git",
"url": "https://github.com/pestphp/pest-plugin-browser.git",
- "reference": "48bc408033281974952a6b296592cef3b920a2db"
+ "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db",
- "reference": "48bc408033281974952a6b296592cef3b920a2db",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/b6e76d3e4a2f81da9f050ec54be2a29b402287c4",
+ "reference": "b6e76d3e4a2f81da9f050ec54be2a29b402287c4",
"shasum": ""
},
"require": {
@@ -14607,20 +15368,20 @@
"amphp/http-server": "^3.4.4",
"amphp/websocket-client": "^2.0.2",
"ext-sockets": "*",
- "pestphp/pest": "^4.3.2",
+ "pestphp/pest": "^4.4.5",
"pestphp/pest-plugin": "^4.0.0",
"php": "^8.3",
- "symfony/process": "^7.4.5|^8.0.5"
+ "symfony/process": "^7.4.8|^8.0.5"
},
"require-dev": {
"ext-pcntl": "*",
"ext-posix": "*",
- "livewire/livewire": "^3.7.10",
- "nunomaduro/collision": "^8.9.0",
- "orchestra/testbench": "^10.9.0",
+ "livewire/livewire": "^3.7.15",
+ "nunomaduro/collision": "^8.9.3",
+ "orchestra/testbench": "^10.11.0",
"pestphp/pest-dev-tools": "^4.1.0",
- "pestphp/pest-plugin-laravel": "^4.0",
- "pestphp/pest-plugin-type-coverage": "^4.0.3"
+ "pestphp/pest-plugin-laravel": "^4.1",
+ "pestphp/pest-plugin-type-coverage": "^4.0.4"
},
"type": "library",
"extra": {
@@ -14653,7 +15414,7 @@
"unit"
],
"support": {
- "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0"
+ "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.1"
},
"funding": [
{
@@ -14669,7 +15430,7 @@
"type": "patreon"
}
],
- "time": "2026-02-17T14:54:40+00:00"
+ "time": "2026-04-08T21:04:12+00:00"
},
{
"name": "pestphp/pest-plugin-mutate",
@@ -15063,11 +15824,11 @@
},
{
"name": "phpstan/phpstan",
- "version": "2.1.44",
+ "version": "2.1.55",
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpstan/phpstan/zipball/4a88c083c668b2c364a425c9b3171b2d9ea5d218",
- "reference": "4a88c083c668b2c364a425c9b3171b2d9ea5d218",
+ "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566",
+ "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566",
"shasum": ""
},
"require": {
@@ -15112,20 +15873,20 @@
"type": "github"
}
],
- "time": "2026-03-25T17:34:21+00:00"
+ "time": "2026-05-18T11:57:34+00:00"
},
{
"name": "phpunit/php-code-coverage",
- "version": "12.5.3",
+ "version": "12.5.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d"
+ "reference": "876099a072646c7745f673d7aeab5382c4439691"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b015312f28dd75b75d3422ca37dff2cd1a565e8d",
- "reference": "b015312f28dd75b75d3422ca37dff2cd1a565e8d",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691",
+ "reference": "876099a072646c7745f673d7aeab5382c4439691",
"shasum": ""
},
"require": {
@@ -15134,7 +15895,6 @@
"ext-xmlwriter": "*",
"nikic/php-parser": "^5.7.0",
"php": ">=8.3",
- "phpunit/php-file-iterator": "^6.0",
"phpunit/php-text-template": "^5.0",
"sebastian/complexity": "^5.0",
"sebastian/environment": "^8.0.3",
@@ -15181,7 +15941,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
- "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.3"
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6"
},
"funding": [
{
@@ -15201,7 +15961,7 @@
"type": "tidelift"
}
],
- "time": "2026-02-06T06:01:44+00:00"
+ "time": "2026-04-15T08:23:17+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -15462,16 +16222,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "12.5.14",
+ "version": "12.5.24",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0"
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/47283cfd98d553edcb1353591f4e255dc1bb61f0",
- "reference": "47283cfd98d553edcb1353591f4e255dc1bb61f0",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046",
+ "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046",
"shasum": ""
},
"require": {
@@ -15485,15 +16245,15 @@
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=8.3",
- "phpunit/php-code-coverage": "^12.5.3",
+ "phpunit/php-code-coverage": "^12.5.6",
"phpunit/php-file-iterator": "^6.0.1",
"phpunit/php-invoker": "^6.0.0",
"phpunit/php-text-template": "^5.0.0",
"phpunit/php-timer": "^8.0.0",
"sebastian/cli-parser": "^4.2.0",
- "sebastian/comparator": "^7.1.4",
+ "sebastian/comparator": "^7.1.6",
"sebastian/diff": "^7.0.0",
- "sebastian/environment": "^8.0.3",
+ "sebastian/environment": "^8.1.0",
"sebastian/exporter": "^7.0.2",
"sebastian/global-state": "^8.0.2",
"sebastian/object-enumerator": "^7.0.0",
@@ -15540,49 +16300,33 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.14"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.24"
},
"funding": [
{
- "url": "https://phpunit.de/sponsors.html",
- "type": "custom"
- },
- {
- "url": "https://github.com/sebastianbergmann",
- "type": "github"
- },
- {
- "url": "https://liberapay.com/sebastianbergmann",
- "type": "liberapay"
- },
- {
- "url": "https://thanks.dev/u/gh/sebastianbergmann",
- "type": "thanks_dev"
- },
- {
- "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
- "type": "tidelift"
+ "url": "https://phpunit.de/sponsoring.html",
+ "type": "other"
}
],
- "time": "2026-02-18T12:38:40+00:00"
+ "time": "2026-05-01T04:21:04+00:00"
},
{
"name": "rector/rector",
- "version": "2.3.9",
+ "version": "2.4.4",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
- "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4"
+ "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/rectorphp/rector/zipball/917842143fd9f5331a2adefc214b8d7143bd32c4",
- "reference": "917842143fd9f5331a2adefc214b8d7143bd32c4",
+ "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091",
+ "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091",
"shasum": ""
},
"require": {
"php": "^7.4|^8.0",
- "phpstan/phpstan": "^2.1.40"
+ "phpstan/phpstan": "^2.1.48"
},
"conflict": {
"rector/rector-doctrine": "*",
@@ -15616,7 +16360,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
- "source": "https://github.com/rectorphp/rector/tree/2.3.9"
+ "source": "https://github.com/rectorphp/rector/tree/2.4.4"
},
"funding": [
{
@@ -15624,20 +16368,20 @@
"type": "github"
}
],
- "time": "2026-03-16T09:43:55+00:00"
+ "time": "2026-05-20T19:30:21+00:00"
},
{
"name": "revolt/event-loop",
- "version": "v1.0.8",
+ "version": "v1.0.9",
"source": {
"type": "git",
"url": "https://github.com/revoltphp/event-loop.git",
- "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c"
+ "reference": "44061cf513e53c6200372fc935ac42271566295d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c",
- "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c",
+ "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d",
"shasum": ""
},
"require": {
@@ -15647,7 +16391,7 @@
"ext-json": "*",
"jetbrains/phpstorm-stubs": "^2019.3",
"phpunit/phpunit": "^9",
- "psalm/phar": "^5.15"
+ "psalm/phar": "6.16.*"
},
"type": "library",
"extra": {
@@ -15694,29 +16438,29 @@
],
"support": {
"issues": "https://github.com/revoltphp/event-loop/issues",
- "source": "https://github.com/revoltphp/event-loop/tree/v1.0.8"
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9"
},
- "time": "2025-08-27T21:33:23+00:00"
+ "time": "2026-05-16T17:55:38+00:00"
},
{
"name": "sebastian/cli-parser",
- "version": "4.2.0",
+ "version": "4.2.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/cli-parser.git",
- "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04"
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04",
- "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15",
+ "reference": "7d05781b13f7dec9043a629a21d086ed74582a15",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -15745,7 +16489,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/cli-parser/issues",
"security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
- "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0"
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1"
},
"funding": [
{
@@ -15765,20 +16509,20 @@
"type": "tidelift"
}
],
- "time": "2025-09-14T09:36:45+00:00"
+ "time": "2026-05-17T05:29:34+00:00"
},
{
"name": "sebastian/comparator",
- "version": "7.1.4",
+ "version": "7.1.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6"
+ "reference": "7c65c1e79836812819705b473a90c12399542485"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/6a7de5df2e094f9a80b40a522391a7e6022df5f6",
- "reference": "6a7de5df2e094f9a80b40a522391a7e6022df5f6",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485",
+ "reference": "7c65c1e79836812819705b473a90c12399542485",
"shasum": ""
},
"require": {
@@ -15786,10 +16530,10 @@
"ext-mbstring": "*",
"php": ">=8.3",
"sebastian/diff": "^7.0",
- "sebastian/exporter": "^7.0"
+ "sebastian/exporter": "^7.0.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.2"
+ "phpunit/phpunit": "^12.5.25"
},
"suggest": {
"ext-bcmath": "For comparing BcMath\\Number objects"
@@ -15837,7 +16581,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"security": "https://github.com/sebastianbergmann/comparator/security/policy",
- "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.4"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8"
},
"funding": [
{
@@ -15857,7 +16601,7 @@
"type": "tidelift"
}
],
- "time": "2026-01-24T09:28:48+00:00"
+ "time": "2026-05-21T04:45:25+00:00"
},
{
"name": "sebastian/complexity",
@@ -15986,16 +16730,16 @@
},
{
"name": "sebastian/environment",
- "version": "8.0.4",
+ "version": "8.1.0",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/environment.git",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11"
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
- "reference": "7b8842c2d8e85d0c3a5831236bf5869af6ab2a11",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6",
+ "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6",
"shasum": ""
},
"require": {
@@ -16010,7 +16754,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "8.0-dev"
+ "dev-main": "8.1-dev"
}
},
"autoload": {
@@ -16038,7 +16782,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/environment/issues",
"security": "https://github.com/sebastianbergmann/environment/security/policy",
- "source": "https://github.com/sebastianbergmann/environment/tree/8.0.4"
+ "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0"
},
"funding": [
{
@@ -16058,29 +16802,29 @@
"type": "tidelift"
}
],
- "time": "2026-03-15T07:05:40+00:00"
+ "time": "2026-04-15T12:13:01+00:00"
},
{
"name": "sebastian/exporter",
- "version": "7.0.2",
+ "version": "7.0.3",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "016951ae10980765e4e7aee491eb288c64e505b7"
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7",
- "reference": "016951ae10980765e4e7aee491eb288c64e505b7",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
+ "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": ">=8.3",
- "sebastian/recursion-context": "^7.0"
+ "sebastian/recursion-context": "^7.0.1"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -16128,7 +16872,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"security": "https://github.com/sebastianbergmann/exporter/security/policy",
- "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3"
},
"funding": [
{
@@ -16148,7 +16892,7 @@
"type": "tidelift"
}
],
- "time": "2025-09-24T06:16:11+00:00"
+ "time": "2026-05-20T04:37:17+00:00"
},
{
"name": "sebastian/global-state",
@@ -16226,24 +16970,24 @@
},
{
"name": "sebastian/lines-of-code",
- "version": "4.0.0",
+ "version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/lines-of-code.git",
- "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f"
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f",
- "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e",
+ "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e",
"shasum": ""
},
"require": {
- "nikic/php-parser": "^5.0",
+ "nikic/php-parser": "^5.7.0",
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -16272,15 +17016,27 @@
"support": {
"issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
"security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
- "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0"
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code",
+ "type": "tidelift"
}
],
- "time": "2025-02-07T04:57:28+00:00"
+ "time": "2026-05-19T16:22:07+00:00"
},
{
"name": "sebastian/object-enumerator",
@@ -16474,23 +17230,23 @@
},
{
"name": "sebastian/type",
- "version": "6.0.3",
+ "version": "6.0.4",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/type.git",
- "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d"
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d",
- "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773",
+ "reference": "82ff822c2edc46724be9f7411d3163021f602773",
"shasum": ""
},
"require": {
"php": ">=8.3"
},
"require-dev": {
- "phpunit/phpunit": "^12.0"
+ "phpunit/phpunit": "^12.5.25"
},
"type": "library",
"extra": {
@@ -16519,7 +17275,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/type/issues",
"security": "https://github.com/sebastianbergmann/type/security/policy",
- "source": "https://github.com/sebastianbergmann/type/tree/6.0.3"
+ "source": "https://github.com/sebastianbergmann/type/tree/6.0.4"
},
"funding": [
{
@@ -16539,7 +17295,7 @@
"type": "tidelift"
}
],
- "time": "2025-08-09T06:57:12+00:00"
+ "time": "2026-05-20T06:45:45+00:00"
},
{
"name": "sebastian/version",
@@ -16597,20 +17353,21 @@
},
{
"name": "serversideup/spin",
- "version": "v3.1.1",
+ "version": "v3.2.3",
"source": {
"type": "git",
"url": "https://github.com/serversideup/spin.git",
- "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd"
+ "reference": "764b09fdfe83249117abfd913af4103b75edc586"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/serversideup/spin/zipball/5da5b5485b03e4f75d501b93b8a7e8ab973157cd",
- "reference": "5da5b5485b03e4f75d501b93b8a7e8ab973157cd",
+ "url": "https://api.github.com/repos/serversideup/spin/zipball/764b09fdfe83249117abfd913af4103b75edc586",
+ "reference": "764b09fdfe83249117abfd913af4103b75edc586",
"shasum": ""
},
"bin": [
- "bin/spin"
+ "bin/spin",
+ "bin/spin-mcp-wait.sh"
],
"type": "library",
"notification-url": "https://packagist.org/downloads/",
@@ -16630,7 +17387,7 @@
"description": "Replicate your production environment locally using Docker. Just run \"spin up\". It's really that easy.",
"support": {
"issues": "https://github.com/serversideup/spin/issues",
- "source": "https://github.com/serversideup/spin/tree/v3.1.1"
+ "source": "https://github.com/serversideup/spin/tree/v3.2.3"
},
"funding": [
{
@@ -16638,7 +17395,7 @@
"type": "github"
}
],
- "time": "2025-11-06T19:13:57+00:00"
+ "time": "2026-04-16T21:33:58+00:00"
},
{
"name": "spatie/error-solutions",
@@ -16716,16 +17473,16 @@
},
{
"name": "spatie/flare-client-php",
- "version": "1.11.0",
+ "version": "1.11.1",
"source": {
"type": "git",
"url": "https://github.com/spatie/flare-client-php.git",
- "reference": "fb3ffb946675dba811fbde9122224db2f84daca9"
+ "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/fb3ffb946675dba811fbde9122224db2f84daca9",
- "reference": "fb3ffb946675dba811fbde9122224db2f84daca9",
+ "url": "https://api.github.com/repos/spatie/flare-client-php/zipball/53f41b08a27cc039e1a8ed2be9a202e924f31bad",
+ "reference": "53f41b08a27cc039e1a8ed2be9a202e924f31bad",
"shasum": ""
},
"require": {
@@ -16773,7 +17530,7 @@
],
"support": {
"issues": "https://github.com/spatie/flare-client-php/issues",
- "source": "https://github.com/spatie/flare-client-php/tree/1.11.0"
+ "source": "https://github.com/spatie/flare-client-php/tree/1.11.1"
},
"funding": [
{
@@ -16781,7 +17538,7 @@
"type": "github"
}
],
- "time": "2026-03-17T08:06:16+00:00"
+ "time": "2026-05-15T09:31:32+00:00"
},
{
"name": "spatie/ignition",
@@ -17015,16 +17772,16 @@
},
{
"name": "symfony/http-client",
- "version": "v7.4.7",
+ "version": "v7.4.9",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client.git",
- "reference": "1010624285470eb60e88ed10035102c75b4ea6af"
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client/zipball/1010624285470eb60e88ed10035102c75b4ea6af",
- "reference": "1010624285470eb60e88ed10035102c75b4ea6af",
+ "url": "https://api.github.com/repos/symfony/http-client/zipball/7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
+ "reference": "7e941c6abf4e3bf7dca160bf0e11ef36a9f832f6",
"shasum": ""
},
"require": {
@@ -17092,7 +17849,7 @@
"http"
],
"support": {
- "source": "https://github.com/symfony/http-client/tree/v7.4.7"
+ "source": "https://github.com/symfony/http-client/tree/v7.4.9"
},
"funding": [
{
@@ -17112,20 +17869,20 @@
"type": "tidelift"
}
],
- "time": "2026-03-05T11:16:58+00:00"
+ "time": "2026-04-29T13:25:15+00:00"
},
{
"name": "symfony/http-client-contracts",
- "version": "v3.6.0",
+ "version": "v3.7.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-client-contracts.git",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c"
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/75d7043853a42837e68111812f4d964b01e5101c",
- "reference": "75d7043853a42837e68111812f4d964b01e5101c",
+ "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
+ "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d",
"shasum": ""
},
"require": {
@@ -17138,7 +17895,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
- "dev-main": "3.6-dev"
+ "dev-main": "3.7-dev"
}
},
"autoload": {
@@ -17174,7 +17931,7 @@
"standards"
],
"support": {
- "source": "https://github.com/symfony/http-client-contracts/tree/v3.6.0"
+ "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0"
},
"funding": [
{
@@ -17185,12 +17942,16 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
- "time": "2025-04-29T11:18:49+00:00"
+ "time": "2026-03-06T13:17:50+00:00"
},
{
"name": "ta-tikoma/phpunit-architecture-test",
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..a01669673 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,9 +2,10 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.474',
- 'helper_version' => '1.0.13',
- 'realtime_version' => '1.0.13',
+ 'version' => '4.1.2',
+ 'helper_version' => '1.0.14',
+ 'realtime_version' => '1.0.16',
+ 'railpack_version' => '0.23.0',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
@@ -15,7 +16,7 @@ return [
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
'upgrade_script_url' => env('UPGRADE_SCRIPT_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/upgrade.sh'),
- 'releases_url' => 'https://cdn.coolify.io/releases.json',
+ 'releases_url' => env('RELEASES_URL', 'https://raw.githubusercontent.com/coollabsio/coolify-cdn/main/json/releases.json'),
],
'urls' => [
@@ -34,6 +35,7 @@ return [
'protocol' => env('TERMINAL_PROTOCOL'),
'host' => env('TERMINAL_HOST'),
'port' => env('TERMINAL_PORT'),
+ 'command_timeout' => 0,
],
'pusher' => [
@@ -69,6 +71,10 @@ return [
'mux_health_check_enabled' => env('SSH_MUX_HEALTH_CHECK_ENABLED', true),
'mux_health_check_timeout' => env('SSH_MUX_HEALTH_CHECK_TIMEOUT', 5),
'mux_max_age' => env('SSH_MUX_MAX_AGE', 1800), // 30 minutes
+ 'mux_lock_ttl' => env('SSH_MUX_LOCK_TTL', 30), // lock auto-release, seconds
+ 'mux_lock_timeout' => env('SSH_MUX_LOCK_TIMEOUT', 10), // max wait for lock, seconds
+ 'mux_orphan_min_age' => env('SSH_MUX_ORPHAN_MIN_AGE', 600), // min process age before reaping orphans, seconds
+ 'mux_orphan_reap_enabled' => env('SSH_MUX_ORPHAN_REAP_ENABLED', false), // false = dry-run, only log orphans
'connection_timeout' => 10,
'server_interval' => 20,
'command_timeout' => 3600,
@@ -93,6 +99,23 @@ return [
'sentry_dsn' => env('SENTRY_DSN'),
],
+ 'sentinel' => [
+ // How often (seconds) PushServerUpdateJob is force-dispatched even when
+ // the container state hash is unchanged. Keeps exited-detection and
+ // storage checks from going stale without writing every resource row on
+ // every push.
+ 'push_force_interval_seconds' => env('SENTINEL_PUSH_FORCE_INTERVAL_SECONDS', 300),
+
+ ],
+
+ 'proxy' => [
+ // How often (seconds) PushServerUpdateJob periodically re-connects the
+ // proxy to Docker networks as a safety net. Real network-layout changes
+ // already connect the proxy on-demand; this only covers gaps (Swarm
+ // networks added via UI, proxy crash recovery).
+ 'connect_networks_interval_seconds' => env('PROXY_CONNECT_NETWORKS_INTERVAL_SECONDS', 3600),
+ ],
+
'webhooks' => [
'feedback_discord_webhook' => env('FEEDBACK_DISCORD_WEBHOOK'),
'dev_webhook' => env('SERVEO_URL'),
diff --git a/config/database.php b/config/database.php
index a5e0ba703..9238a7055 100644
--- a/config/database.php
+++ b/config/database.php
@@ -1,6 +1,64 @@
'pgsql',
+ 'url' => env('DATABASE_URL'),
+ 'host' => env('DB_HOST', 'coolify-db'),
+ 'port' => env('DB_PORT', '5432'),
+ 'database' => env('DB_DATABASE', 'coolify'),
+ 'username' => env('DB_USERNAME', 'coolify'),
+ 'password' => env('DB_PASSWORD', ''),
+ 'charset' => 'utf8',
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'search_path' => 'public',
+ 'sslmode' => 'prefer',
+ 'options' => [
+ (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? Pgsql::ATTR_DISABLE_PREPARES : PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
+ ],
+];
+
+/*
+ * Opt-in read/write replica split. Activates only when DB_READ_HOST is set.
+ * When unset, the pgsql connection is identical to a single-primary setup.
+ * Hosts may be comma-separated; Laravel random-picks one per connection.
+ */
+if (env('DB_READ_HOST')) {
+ $pgsql['read'] = [
+ 'host' => $parseDatabaseHosts(env('DB_READ_HOST'), env('DB_HOST', 'coolify-db')),
+ 'port' => env('DB_READ_PORT', env('DB_PORT', '5432')),
+ 'username' => env('DB_READ_USERNAME', env('DB_USERNAME', 'coolify')),
+ 'password' => env('DB_READ_PASSWORD', env('DB_PASSWORD', '')),
+ ];
+ $pgsql['write'] = [
+ 'host' => $parseDatabaseHosts(env('DB_WRITE_HOST'), env('DB_HOST', 'coolify-db')),
+ 'port' => env('DB_WRITE_PORT', env('DB_PORT', '5432')),
+ 'username' => env('DB_WRITE_USERNAME', env('DB_USERNAME', 'coolify')),
+ 'password' => env('DB_WRITE_PASSWORD', env('DB_PASSWORD', '')),
+ ];
+ $pgsql['sticky'] = (bool) env('DB_STICKY', true);
+}
return [
@@ -35,23 +93,7 @@ return [
'connections' => [
- 'pgsql' => [
- 'driver' => 'pgsql',
- 'url' => env('DATABASE_URL'),
- 'host' => env('DB_HOST', 'coolify-db'),
- 'port' => env('DB_PORT', '5432'),
- 'database' => env('DB_DATABASE', 'coolify'),
- 'username' => env('DB_USERNAME', 'coolify'),
- 'password' => env('DB_PASSWORD', ''),
- 'charset' => 'utf8',
- 'prefix' => '',
- 'prefix_indexes' => true,
- 'search_path' => 'public',
- 'sslmode' => 'prefer',
- 'options' => [
- (defined('Pdo\Pgsql::ATTR_DISABLE_PREPARES') ? \Pdo\Pgsql::ATTR_DISABLE_PREPARES : \PDO::PGSQL_ATTR_DISABLE_PREPARES) => env('DB_DISABLE_PREPARES', false),
- ],
- ],
+ 'pgsql' => $pgsql,
'testing' => [
'driver' => 'sqlite',
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 @@ return [
'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/config/purify.php b/config/purify.php
index a5dcabb92..3d181d6eb 100644
--- a/config/purify.php
+++ b/config/purify.php
@@ -1,5 +1,6 @@
[
'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')),
- 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class,
+ 'cache' => CacheDefinitionCache::class,
],
// 'serializer' => [
diff --git a/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php
new file mode 100644
index 000000000..cc702ce5c
--- /dev/null
+++ b/database/migrations/2025_11_05_091558_add_stop_grace_period_to_application_settings.php
@@ -0,0 +1,31 @@
+integer('stop_grace_period')
+ ->nullable()
+ ->after('use_build_secrets')
+ ->comment('Seconds to wait for graceful shutdown before forcing container stop (1-3600). Null uses default of 30 seconds.');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('application_settings', function (Blueprint $table) {
+ $table->dropColumn('stop_grace_period');
+ });
+ }
+};
diff --git a/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php
new file mode 100644
index 000000000..ac7b5cb55
--- /dev/null
+++ b/database/migrations/2026_03_26_000000_make_ports_exposes_nullable_in_applications_table.php
@@ -0,0 +1,22 @@
+string('ports_exposes')->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('applications', function (Blueprint $table) {
+ $table->string('ports_exposes')->nullable(false)->default('')->change();
+ });
+ }
+};
diff --git a/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php
new file mode 100644
index 000000000..578959c9a
--- /dev/null
+++ b/database/migrations/2026_03_27_000000_add_max_restart_count_to_applications.php
@@ -0,0 +1,22 @@
+integer('max_restart_count')->default(10)->after('restart_count');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('applications', function (Blueprint $blueprint) {
+ $blueprint->dropColumn('max_restart_count');
+ });
+ }
+};
diff --git a/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php
new file mode 100644
index 000000000..47ee6e30a
--- /dev/null
+++ b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php
@@ -0,0 +1,59 @@
+text($col)->nullable()->change();
+ }
+ });
+
+ try {
+ DB::table('applications')->chunkById(100, function ($apps) use ($columns) {
+ foreach ($apps as $app) {
+ $updates = [];
+ foreach ($columns as $col) {
+ $current = $app->{$col};
+
+ if (empty($current)) {
+ $updates[$col] = Crypt::encryptString(Str::random(40));
+
+ continue;
+ }
+
+ try {
+ Crypt::decryptString($current);
+
+ continue;
+ } catch (Exception) {
+ // Not encrypted yet
+ }
+
+ $updates[$col] = Crypt::encryptString($current);
+ }
+ if ($updates !== []) {
+ DB::table('applications')->where('id', $app->id)->update($updates);
+ }
+ }
+ });
+ } catch (Exception $e) {
+ echo 'Backfilling and encrypting webhook secrets failed.';
+ echo $e->getMessage();
+ }
+ }
+}
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/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php
new file mode 100644
index 000000000..6a173d058
--- /dev/null
+++ b/database/migrations/2026_05_11_000000_add_configuration_snapshot_to_application_deployment_queues_table.php
@@ -0,0 +1,28 @@
+string('configuration_hash')->nullable()->after('docker_registry_image_tag');
+ $table->json('configuration_snapshot')->nullable()->after('configuration_hash');
+ $table->json('configuration_diff')->nullable()->after('configuration_snapshot');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('application_deployment_queues', function (Blueprint $table) {
+ $table->dropColumn([
+ 'configuration_hash',
+ 'configuration_snapshot',
+ 'configuration_diff',
+ ]);
+ });
+ }
+};
diff --git a/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php b/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php
new file mode 100644
index 000000000..728115482
--- /dev/null
+++ b/database/migrations/2026_05_13_000000_add_expiration_warning_sent_at_to_personal_access_tokens_table.php
@@ -0,0 +1,30 @@
+timestamp('api_token_expiration_warning_sent_at')->nullable()->after('expires_at');
+ $table->index(['expires_at', 'api_token_expiration_warning_sent_at'], 'personal_access_tokens_expiration_warning_index');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('personal_access_tokens', function (Blueprint $table) {
+ $table->dropIndex('personal_access_tokens_expiration_warning_index');
+ $table->dropColumn('api_token_expiration_warning_sent_at');
+ });
+ }
+};
diff --git a/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php
new file mode 100644
index 000000000..e74929147
--- /dev/null
+++ b/database/migrations/2026_05_27_000000_add_push_server_update_job_indexes.php
@@ -0,0 +1,27 @@
+getDriverName() !== 'pgsql') {
+ return;
+ }
+
+ // Fillfactor < 100 leaves free space per page so Postgres can do HOT
+ // (Heap-Only Tuple) in-place updates instead of allocating a new tuple
+ // elsewhere. Coolify's hot-update tables churn rows on every Sentinel
+ // push / status change; without page-local headroom, non-HOT updates
+ // accumulate dead tuples and bloat the heap (we've seen up to 50× on
+ // cloud). Lower fillfactor on hot-update tables, default on the rest.
+ DB::statement('ALTER TABLE applications SET (fillfactor = 70)');
+ DB::statement('ALTER TABLE servers SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE services SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE service_applications SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE service_databases SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_postgresqls SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_redis SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_mongodbs SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_mysqls SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_mariadbs SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_keydbs SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_dragonflies SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE standalone_clickhouses SET (fillfactor = 85)');
+ DB::statement('ALTER TABLE application_deployment_queues SET (fillfactor = 90)');
+
+ // Autovacuum default kicks in at 20% dead tuples — too lazy for our
+ // churn rate. Trigger at 5% on the highest-write tables to keep heap
+ // pages tidy and prevent visibility-map gaps that hurt scan plans.
+ DB::statement('ALTER TABLE applications SET (autovacuum_vacuum_scale_factor = 0.05)');
+ DB::statement('ALTER TABLE servers SET (autovacuum_vacuum_scale_factor = 0.05)');
+ DB::statement('ALTER TABLE service_applications SET (autovacuum_vacuum_scale_factor = 0.05)');
+ DB::statement('ALTER TABLE service_databases SET (autovacuum_vacuum_scale_factor = 0.05)');
+ DB::statement('ALTER TABLE standalone_postgresqls SET (autovacuum_vacuum_scale_factor = 0.05)');
+ }
+
+ public function down(): void
+ {
+ if (DB::connection()->getDriverName() !== 'pgsql') {
+ return;
+ }
+
+ DB::statement('ALTER TABLE applications RESET (fillfactor, autovacuum_vacuum_scale_factor)');
+ DB::statement('ALTER TABLE servers RESET (fillfactor, autovacuum_vacuum_scale_factor)');
+ DB::statement('ALTER TABLE services RESET (fillfactor)');
+ DB::statement('ALTER TABLE service_applications RESET (fillfactor, autovacuum_vacuum_scale_factor)');
+ DB::statement('ALTER TABLE service_databases RESET (fillfactor, autovacuum_vacuum_scale_factor)');
+ DB::statement('ALTER TABLE standalone_postgresqls RESET (fillfactor, autovacuum_vacuum_scale_factor)');
+ DB::statement('ALTER TABLE standalone_redis RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_mongodbs RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_mysqls RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_mariadbs RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_keydbs RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_dragonflies RESET (fillfactor)');
+ DB::statement('ALTER TABLE standalone_clickhouses RESET (fillfactor)');
+ DB::statement('ALTER TABLE application_deployment_queues RESET (fillfactor)');
+ }
+};
diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
new file mode 100644
index 000000000..123fd226d
--- /dev/null
+++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php
@@ -0,0 +1,23 @@
+tables as $table) {
+ Schema::table($table, function (Blueprint $table) {
+ $table->boolean('health_check_enabled')->default(true);
+ $table->integer('health_check_interval')->default(15);
+ $table->integer('health_check_timeout')->default(5);
+ $table->integer('health_check_retries')->default(5);
+ $table->integer('health_check_start_period')->default(5);
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ foreach ($this->tables as $table) {
+ Schema::table($table, function (Blueprint $table) {
+ $table->dropColumn([
+ 'health_check_enabled',
+ 'health_check_interval',
+ 'health_check_timeout',
+ 'health_check_retries',
+ 'health_check_start_period',
+ ]);
+ });
+ }
+ }
+};
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 @@ class DatabaseSeeder extends Seeder
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 @@ class InstanceSettingsSeeder extends Seeder
'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/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php
index 511af1a9f..4d492a297 100644
--- a/database/seeders/ProductionSeeder.php
+++ b/database/seeders/ProductionSeeder.php
@@ -32,6 +32,16 @@ class ProductionSeeder extends Seeder
echo " Running in self-hosted mode.\n";
}
+ if (Team::find(0) === null) {
+ (new Team)->forceFill([
+ 'id' => 0,
+ 'name' => 'Root Team',
+ 'description' => 'The root team',
+ 'personal_team' => true,
+ 'show_boarding' => true,
+ ])->save();
+ }
+
if (User::find(0) !== null && Team::find(0) !== null) {
if (DB::table('team_user')->where('user_id', 0)->first() === null) {
DB::table('team_user')->insert([
diff --git a/database/seeders/RootUserSeeder.php b/database/seeders/RootUserSeeder.php
index c4e93af63..9bc93a9a9 100644
--- a/database/seeders/RootUserSeeder.php
+++ b/database/seeders/RootUserSeeder.php
@@ -3,6 +3,7 @@
namespace Database\Seeders;
use App\Models\InstanceSettings;
+use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
@@ -52,6 +53,12 @@ class RootUserSeeder extends Seeder
'password' => Hash::make(env('ROOT_USER_PASSWORD')),
]);
$user->save();
+
+ $team = Team::find(0);
+ if ($team !== null && ! $user->teams()->where('team_id', 0)->exists()) {
+ $user->teams()->attach($team, ['role' => 'owner']);
+ }
+
echo "\n SUCCESS Root user created successfully.\n\n";
} catch (\Exception $e) {
echo "\n ERROR Failed to create root user: {$e->getMessage()}\n\n";
diff --git a/database/seeders/SharedEnvironmentVariableSeeder.php b/database/seeders/SharedEnvironmentVariableSeeder.php
index 7a17fbd10..cfd2a3fef 100644
--- a/database/seeders/SharedEnvironmentVariableSeeder.php
+++ b/database/seeders/SharedEnvironmentVariableSeeder.php
@@ -35,7 +35,7 @@ class SharedEnvironmentVariableSeeder extends Seeder
]);
// Add predefined server variables to all existing servers
- $servers = \App\Models\Server::all();
+ $servers = Server::all();
foreach ($servers as $server) {
SharedEnvironmentVariable::firstOrCreate([
'key' => 'COOLIFY_SERVER_UUID',
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index f608fe3cb..9c93678af 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: 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..8907a30b9 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.16'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 998d35974..da045fe03 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.16'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-helper/Dockerfile b/docker/coolify-helper/Dockerfile
index 9c984a5ee..6bea6ba1b 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.23.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.17
# 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/Dockerfile b/docker/coolify-realtime/Dockerfile
index 325a30dcc..8395d6f87 100644
--- a/docker/coolify-realtime/Dockerfile
+++ b/docker/coolify-realtime/Dockerfile
@@ -12,8 +12,8 @@ ARG CLOUDFLARED_VERSION
WORKDIR /terminal
RUN apk upgrade --no-cache && \
apk add --no-cache openssh-client make g++ python3 curl
-COPY docker/coolify-realtime/package.json ./
-RUN npm i
+COPY docker/coolify-realtime/package*.json ./
+RUN npm ci
RUN npm rebuild node-pty --update-binary
COPY docker/coolify-realtime/soketi-entrypoint.sh /soketi-entrypoint.sh
COPY docker/coolify-realtime/terminal-server.js /terminal/terminal-server.js
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 174077562..cdb29bffa 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -7,11 +7,10 @@
"dependencies": {
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
- "axios": "1.15.0",
"cookie": "1.1.1",
"dotenv": "17.3.1",
"node-pty": "1.1.0",
- "ws": "8.19.0"
+ "ws": "8.20.1"
}
},
"node_modules/@xterm/addon-fit": {
@@ -29,48 +28,6 @@
"addons/*"
]
},
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
- "proxy-from-env": "^2.1.0"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
@@ -84,15 +41,6 @@
"url": "https://opencollective.com/express"
}
},
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -105,228 +53,6 @@
"url": "https://dotenvx.com"
}
},
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "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==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -343,19 +69,10 @@
"node-addon-api": "^7.1.0"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/ws": {
- "version": "8.19.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
- "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "version": "8.20.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+ "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
diff --git a/docker/coolify-realtime/package.json b/docker/coolify-realtime/package.json
index 30bfbcef7..9128c0c3f 100644
--- a/docker/coolify-realtime/package.json
+++ b/docker/coolify-realtime/package.json
@@ -5,9 +5,8 @@
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"cookie": "1.1.1",
- "axios": "1.15.0",
"dotenv": "17.3.1",
"node-pty": "1.1.0",
- "ws": "8.19.0"
+ "ws": "8.20.1"
}
-}
\ No newline at end of file
+}
diff --git a/docker/coolify-realtime/soketi-entrypoint.sh b/docker/coolify-realtime/soketi-entrypoint.sh
index 3bb85bdeb..7197e4a0c 100644
--- a/docker/coolify-realtime/soketi-entrypoint.sh
+++ b/docker/coolify-realtime/soketi-entrypoint.sh
@@ -1,35 +1,91 @@
#!/bin/sh
-# Function to timestamp logs
-# Check if the first argument is 'watch'
if [ "$1" = "watch" ]; then
WATCH_MODE="--watch"
else
WATCH_MODE=""
fi
-timestamp() {
- date "+%Y-%m-%d %H:%M:%S"
+log() {
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [ENTRYPOINT] $*"
}
-# Start the terminal server in the background with logging
-node $WATCH_MODE /terminal/terminal-server.js > >(while read line; do echo "$(timestamp) [TERMINAL] $line"; done) 2>&1 &
+start_logger() {
+ prefix="$1"
+ fifo_path="$2"
+
+ while read -r line; do
+ echo "$(date '+%Y-%m-%d %H:%M:%S') [$prefix] $line"
+ done < "$fifo_path" &
+}
+
+cleanup() {
+ rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
+}
+
+TERMINAL_LOG_FIFO="/tmp/coolify-terminal-log.$$"
+SOKETI_LOG_FIFO="/tmp/coolify-soketi-log.$$"
+
+rm -f "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
+mkfifo "$TERMINAL_LOG_FIFO" "$SOKETI_LOG_FIFO"
+
+trap cleanup EXIT
+
+log "Starting realtime container"
+log "WATCH_MODE=${WATCH_MODE:-off}"
+log "SOKETI_DEBUG=${SOKETI_DEBUG:-unset}"
+log "NODE_OPTIONS=${NODE_OPTIONS:-unset}"
+
+start_logger "TERMINAL" "$TERMINAL_LOG_FIFO"
+TERMINAL_LOGGER_PID=$!
+
+start_logger "SOKETI" "$SOKETI_LOG_FIFO"
+SOKETI_LOGGER_PID=$!
+
+node $WATCH_MODE /terminal/terminal-server.js > "$TERMINAL_LOG_FIFO" 2>&1 &
TERMINAL_PID=$!
-# Start the Soketi process in the background with logging
-node /app/bin/server.js start > >(while read line; do echo "$(timestamp) [SOKETI] $line"; done) 2>&1 &
+log "Terminal server started pid=$TERMINAL_PID logger_pid=$TERMINAL_LOGGER_PID"
+
+node /app/bin/server.js start > "$SOKETI_LOG_FIFO" 2>&1 &
SOKETI_PID=$!
-# Function to forward signals to child processes
+log "Soketi started pid=$SOKETI_PID logger_pid=$SOKETI_LOGGER_PID"
+
forward_signal() {
- kill -$1 $TERMINAL_PID $SOKETI_PID
+ log "Forwarding signal $1 to terminal=$TERMINAL_PID soketi=$SOKETI_PID"
+
+ kill -"$1" "$TERMINAL_PID" 2>/dev/null || true
+ kill -"$1" "$SOKETI_PID" 2>/dev/null || true
}
-# Forward SIGTERM to child processes
trap 'forward_signal TERM' TERM
+trap 'forward_signal INT' INT
-# Wait for any process to exit
-wait -n
+while true; do
+ if ! kill -0 "$TERMINAL_PID" 2>/dev/null; then
+ wait "$TERMINAL_PID"
+ EXIT_CODE=$?
-# Exit with status of process that exited first
-exit $?
+ log "Terminal server exited code=$EXIT_CODE; stopping soketi pid=$SOKETI_PID"
+
+ kill "$SOKETI_PID" 2>/dev/null || true
+ wait "$SOKETI_PID" 2>/dev/null || true
+
+ exit "$EXIT_CODE"
+ fi
+
+ if ! kill -0 "$SOKETI_PID" 2>/dev/null; then
+ wait "$SOKETI_PID"
+ EXIT_CODE=$?
+
+ log "Soketi exited code=$EXIT_CODE; stopping terminal pid=$TERMINAL_PID"
+
+ kill "$TERMINAL_PID" 2>/dev/null || true
+ wait "$TERMINAL_PID" 2>/dev/null || true
+
+ exit "$EXIT_CODE"
+ fi
+
+ sleep 1
+done
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 3ae77857f..519792716 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -1,7 +1,6 @@
import { WebSocketServer } from 'ws';
import http from 'http';
import pty from 'node-pty';
-import axios from 'axios';
import cookie from 'cookie';
import 'dotenv/config';
import {
@@ -9,13 +8,67 @@ import {
extractSshArgs,
extractTargetHost,
extractTimeout,
+ getTerminalSessionTimeout,
isAuthorizedTargetHost,
} from './terminal-utils.js';
+async function postToCoolify(path, headers) {
+ return new Promise((resolve, reject) => {
+ const request = http.request({
+ hostname: 'coolify',
+ port: 8080,
+ path,
+ method: 'POST',
+ headers,
+ }, (response) => {
+ let responseText = '';
+
+ response.setEncoding('utf8');
+ response.on('data', (chunk) => {
+ responseText += chunk;
+ });
+ response.on('end', () => {
+ try {
+ resolve({
+ status: response.statusCode ?? 0,
+ data: parseResponseData(response.headers['content-type'], responseText),
+ });
+ } catch (error) {
+ reject(error);
+ }
+ });
+ });
+
+ request.on('error', reject);
+ request.end();
+ });
+}
+
+function parseResponseData(contentType = '', responseText = '') {
+ if (responseText === '') {
+ return null;
+ }
+
+ if (contentType.includes('application/json')) {
+ return JSON.parse(responseText);
+ }
+
+ return responseText;
+}
+
+function createHttpError(response) {
+ const error = new Error(`Request failed with status code ${response.status}`);
+ error.response = response;
+
+ return error;
+}
+
const userSessions = new Map();
-const terminalDebugEnabled = ['local', 'development'].includes(
- String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase()
-);
+const envName = String(process.env.APP_ENV || process.env.NODE_ENV || '').toLowerCase();
+const debugOverride = String(process.env.TERMINAL_DEBUG || '').toLowerCase();
+const terminalDebugEnabled =
+ ['local', 'development'].includes(envName)
+ || ['1', 'true', 'yes', 'on'].includes(debugOverride);
function logTerminal(level, message, context = {}) {
if (!terminalDebugEnabled) {
@@ -74,11 +127,9 @@ const verifyClient = async (info, callback) => {
try {
// Authenticate with Laravel backend
- const response = await axios.post(`http://coolify:8080/terminal/auth`, null, {
- headers: {
- 'Cookie': `${sessionCookieName}=${laravelSession}`,
- 'X-XSRF-TOKEN': xsrfToken
- },
+ const response = await postToCoolify('/terminal/auth', {
+ 'Cookie': `${sessionCookieName}=${laravelSession}`,
+ 'X-XSRF-TOKEN': xsrfToken
});
if (response.status === 200) {
@@ -105,9 +156,24 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
+const HEARTBEAT_INTERVAL_MS = 30000;
+
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: [],
+ authReady: false,
+ pendingMessages: [],
+ terminalSessionTimer: null,
+ };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@@ -117,6 +183,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);
@@ -125,12 +211,15 @@ wss.on('connection', async (ws, req) => {
}
try {
- const response = await axios.post(`http://coolify:8080/terminal/auth/ips`, null, {
- headers: {
- 'Cookie': `${sessionCookieName}=${laravelSession}`,
- 'X-XSRF-TOKEN': xsrfToken
- },
+ const response = await postToCoolify('/terminal/auth/ips', {
+ 'Cookie': `${sessionCookieName}=${laravelSession}`,
+ 'X-XSRF-TOKEN': xsrfToken
});
+
+ if (response.status !== 200) {
+ throw createHttpError(response);
+ }
+
userSession.authorizedIPs = response.data.ipAddresses || [];
logTerminal('log', 'Fetched authorized terminal hosts for websocket session.', {
...connectionContext,
@@ -148,27 +237,40 @@ 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
+ }
+ });
+}, HEARTBEAT_INTERVAL_MS);
+
+wss.on('close', () => clearInterval(heartbeat));
+
const messageHandlers = {
- message: (session, data) => session.ptyProcess.write(data),
+ message: (session, data) => {
+ session.ptyProcess.write(data);
+ },
resize: (session, { cols, rows }) => {
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
@@ -197,12 +299,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')) {
@@ -246,8 +342,14 @@ async function handleCommand(ws, command, userId) {
}
}
+ if (userSession.terminalSessionTimer) {
+ clearTimeout(userSession.terminalSessionTimer);
+ userSession.terminalSessionTimer = null;
+ }
+
const commandString = command[0].split('\n').join(' ');
- const timeout = extractTimeout(commandString);
+ const commandTimeout = extractTimeout(commandString);
+ const terminalSessionTimeout = getTerminalSessionTimeout();
const sshArgs = extractSshArgs(commandString);
const hereDocContent = extractHereDocContent(commandString);
@@ -256,7 +358,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Parsed terminal command metadata.', {
userId,
targetHost,
- timeout,
+ commandTimeout,
+ terminalSessionTimeout,
sshArgs,
authorizedIPs: userSession?.authorizedIPs ?? [],
});
@@ -295,7 +398,8 @@ async function handleCommand(ws, command, userId) {
logTerminal('log', 'Spawning PTY process for terminal session.', {
userId,
targetHost,
- timeout,
+ commandTimeout,
+ terminalSessionTimeout,
});
const ptyProcess = pty.spawn('ssh', sshArgs.concat([hereDocContent]), options);
@@ -317,13 +421,16 @@ async function handleCommand(ws, command, userId) {
});
ws.send('pty-exited');
userSession.isActive = false;
+
+ if (userSession.terminalSessionTimer) {
+ clearTimeout(userSession.terminalSessionTimer);
+ userSession.terminalSessionTimer = null;
+ }
});
- if (timeout) {
- setTimeout(async () => {
- await killPtyProcess(userId);
- }, timeout * 1000);
- }
+ userSession.terminalSessionTimer = setTimeout(async () => {
+ await killPtyProcess(userId);
+ }, terminalSessionTimeout * 1000);
}
async function handleError(err, userId) {
@@ -365,6 +472,11 @@ async function killPtyProcess(userId) {
setTimeout(() => {
if (!session.isActive || !session.ptyProcess) {
+ if (session.terminalSessionTimer) {
+ clearTimeout(session.terminalSessionTimer);
+ session.terminalSessionTimer = null;
+ }
+
logTerminal('log', 'PTY process terminated successfully.', {
userId,
killAttempts,
diff --git a/docker/coolify-realtime/terminal-utils.js b/docker/coolify-realtime/terminal-utils.js
index 7456b282c..8769d62d9 100644
--- a/docker/coolify-realtime/terminal-utils.js
+++ b/docker/coolify-realtime/terminal-utils.js
@@ -1,3 +1,9 @@
+export const MAX_TERMINAL_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60;
+
+export function getTerminalSessionTimeout() {
+ return MAX_TERMINAL_SESSION_TIMEOUT_SECONDS;
+}
+
export function extractTimeout(commandString) {
const timeoutMatch = commandString.match(/timeout (\d+)/);
return timeoutMatch ? parseInt(timeoutMatch[1], 10) : null;
diff --git a/docker/coolify-realtime/terminal-utils.test.js b/docker/coolify-realtime/terminal-utils.test.js
index 3da444155..bf863099b 100644
--- a/docker/coolify-realtime/terminal-utils.test.js
+++ b/docker/coolify-realtime/terminal-utils.test.js
@@ -1,8 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
+ MAX_TERMINAL_SESSION_TIMEOUT_SECONDS,
extractSshArgs,
extractTargetHost,
+ getTerminalSessionTimeout,
isAuthorizedTargetHost,
normalizeHostForAuthorization,
} from './terminal-utils.js';
@@ -45,3 +47,10 @@ test('normalizeHostForAuthorization unwraps bracketed IPv6 hosts', () => {
test('isAuthorizedTargetHost rejects hosts that are not in the allowlist', () => {
assert.equal(isAuthorizedTargetHost("'10.0.0.9'", ['10.0.0.5']), false);
});
+
+
+test('getTerminalSessionTimeout always enforces the maximum terminal session lifetime', () => {
+ assert.equal(getTerminalSessionTimeout(null), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+ assert.equal(getTerminalSessionTimeout(60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+ assert.equal(getTerminalSessionTimeout(MAX_TERMINAL_SESSION_TIMEOUT_SECONDS + 60), MAX_TERMINAL_SESSION_TIMEOUT_SECONDS);
+});
diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile
index 77013e1b9..8fc46e32d 100644
--- a/docker/development/Dockerfile
+++ b/docker/development/Dockerfile
@@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0
# https://www.postgresql.org/support/versioning/
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
+# https://nginx.org/en/linux_packages.html
+ARG NGINX_VERSION=1.31.0-r1
# =================================================================
# Get MinIO client
@@ -24,11 +26,24 @@ ARG GROUP_ID
ARG TARGETPLATFORM
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
+ARG NGINX_VERSION
WORKDIR /var/www/html
USER root
+# Install patched Nginx from the official nginx.org Alpine repository
+RUN set -eux; \
+ apk add --no-cache ca-certificates curl; \
+ NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \
+ NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \
+ sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \
+ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \
+ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \
+ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \
+ rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \
+ nginx -v
+
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx
@@ -38,6 +53,7 @@ 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
+
# Install system dependencies
RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index a01dd595c..0f849785e 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -8,6 +8,8 @@ ARG CLOUDFLARED_VERSION=2025.7.0
# https://www.postgresql.org/support/versioning/
# Note: We are using version 18 of the postgres client (while still using postgres 15 for the postgres server) as version 15 has been removed from Alpine 3.23+ https://pkgs.alpinelinux.org/packages?name=postgresql*-client&branch=v3.23&repo=&arch=x86_64&origin=&flagged=&maintainer=
ARG POSTGRES_VERSION=18
+# https://nginx.org/en/linux_packages.html
+ARG NGINX_VERSION=1.31.0-r1
# Add user/group
ARG USER_ID=9999
@@ -20,6 +22,19 @@ FROM serversideup/php:${SERVERSIDEUP_PHP_VERSION} AS base
USER root
+# Install patched Nginx from the official nginx.org Alpine repository
+ARG NGINX_VERSION
+RUN set -eux; \
+ apk add --no-cache ca-certificates curl; \
+ NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \
+ NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \
+ sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \
+ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \
+ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \
+ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \
+ rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \
+ nginx -v
+
ARG USER_ID
ARG GROUP_ID
@@ -60,12 +75,25 @@ ARG GROUP_ID
ARG TARGETPLATFORM
ARG POSTGRES_VERSION
ARG CLOUDFLARED_VERSION
+ARG NGINX_VERSION
ARG CI=true
WORKDIR /var/www/html
USER root
+# Install patched Nginx from the official nginx.org Alpine repository
+RUN set -eux; \
+ apk add --no-cache ca-certificates curl; \
+ NGINX_ALPINE_VERSION="$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)"; \
+ NGINX_REPOSITORY="https://nginx.org/packages/mainline/alpine/v${NGINX_ALPINE_VERSION}/main"; \
+ sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories; \
+ grep -qxF "@nginx ${NGINX_REPOSITORY}" /etc/apk/repositories || echo "@nginx ${NGINX_REPOSITORY}" >> /etc/apk/repositories; \
+ curl -fsSL https://nginx.org/keys/nginx_signing.rsa.pub -o /etc/apk/keys/nginx_signing.rsa.pub; \
+ apk add --no-cache --upgrade "nginx@nginx=${NGINX_VERSION}"; \
+ rm -f /etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf; \
+ nginx -v
+
RUN docker-php-serversideup-set-id www-data $USER_ID:$GROUP_ID && \
docker-php-serversideup-set-file-permissions --owner $USER_ID:$GROUP_ID --service nginx
diff --git a/docker/testing-host/Dockerfile b/docker/testing-host/Dockerfile
index fdad3cc41..43b16981a 100644
--- a/docker/testing-host/Dockerfile
+++ b/docker/testing-host/Dockerfile
@@ -20,9 +20,22 @@ ENV PATH="/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:
RUN apt update && apt -y install openssh-client openssh-server curl wget git jq jc
RUN mkdir -p ~/.docker/cli-plugins
-RUN 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
-RUN curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64 -o ~/.docker/cli-plugins/docker-compose
-RUN (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)
+
+# Download architecture-matched Docker CLI, buildx, and compose binaries.
+# This image is published as a multi-arch manifest (amd64 + arm64), so the
+# downloaded binaries must match TARGETPLATFORM or they fail with "exec format error"
+# when the container runs on the other architecture.
+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); \
+ elif [ "${TARGETPLATFORM}" = "linux/arm64" ]; then \
+ curl -sSL https://github.com/docker/buildx/releases/download/v${DOCKER_BUILDX_VERSION}/buildx-v${DOCKER_BUILDX_VERSION}.linux-arm64 -o ~/.docker/cli-plugins/docker-buildx && \
+ curl -sSL https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-aarch64 -o ~/.docker/cli-plugins/docker-compose && \
+ (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); \
+ else \
+ echo "Unsupported TARGETPLATFORM: ${TARGETPLATFORM}" && exit 1; \
+ fi
RUN chmod +x ~/.docker/cli-plugins/docker-compose /usr/bin/docker /root/.docker/cli-plugins/docker-buildx
diff --git a/openapi.json b/openapi.json
index e4e03c99d..ca445ade0 100644
--- a/openapi.json
+++ b/openapi.json
@@ -79,8 +79,7 @@
"environment_uuid",
"git_repository",
"git_branch",
- "build_pack",
- "ports_exposes"
+ "build_pack"
],
"properties": {
"project_uuid": {
@@ -111,6 +110,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -525,8 +525,7 @@
"github_app_uuid",
"git_repository",
"git_branch",
- "build_pack",
- "ports_exposes"
+ "build_pack"
],
"properties": {
"project_uuid": {
@@ -569,6 +568,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -975,8 +975,7 @@
"private_key_uuid",
"git_repository",
"git_branch",
- "build_pack",
- "ports_exposes"
+ "build_pack"
],
"properties": {
"project_uuid": {
@@ -1019,6 +1018,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -1448,10 +1448,7 @@
"build_pack": {
"type": "string",
"enum": [
- "nixpacks",
- "static",
- "dockerfile",
- "dockercompose"
+ "dockerfile"
],
"description": "The build pack type."
},
@@ -1775,8 +1772,7 @@
"server_uuid",
"environment_name",
"environment_uuid",
- "docker_registry_image_name",
- "ports_exposes"
+ "docker_registry_image_name"
],
"properties": {
"project_uuid": {
@@ -2092,173 +2088,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 +2286,7 @@
"type": "string",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -3788,6 +3618,70 @@
]
}
},
+ "\/applications\/{uuid}\/previews\/{pull_request_id}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Delete Preview Deployment",
+ "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
+ "operationId": "delete-preview-deployment-by-pull-request-id",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pull_request_id",
+ "in": "path",
+ "description": "Pull request ID of the preview to delete.",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Preview deletion queued.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/cloud-tokens": {
"get": {
"tags": [
@@ -4317,8 +4211,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",
@@ -4329,8 +4223,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",
@@ -4707,6 +4601,35 @@
"mysql_conf": {
"type": "string",
"description": "MySQL conf"
+ },
+ "health_check_enabled": {
+ "type": "boolean",
+ "description": "Enable the database healthcheck probe.",
+ "default": true
+ },
+ "health_check_interval": {
+ "type": "integer",
+ "description": "Healthcheck interval in seconds.",
+ "minimum": 1,
+ "default": 15
+ },
+ "health_check_timeout": {
+ "type": "integer",
+ "description": "Healthcheck timeout in seconds.",
+ "minimum": 1,
+ "default": 5
+ },
+ "health_check_retries": {
+ "type": "integer",
+ "description": "Healthcheck retries count.",
+ "minimum": 1,
+ "default": 5
+ },
+ "health_check_start_period": {
+ "type": "integer",
+ "description": "Healthcheck start period in seconds.",
+ "minimum": 0,
+ "default": 5
}
},
"type": "object"
@@ -4887,7 +4810,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": {
@@ -4899,7 +4822,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": {
@@ -8586,6 +8509,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",
@@ -10481,6 +10508,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"
@@ -12435,6 +12466,7 @@
"description": "Build pack.",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -12784,6 +12816,18 @@
"type": "string",
"nullable": true
},
+ "configuration_hash": {
+ "type": "string",
+ "nullable": true
+ },
+ "configuration_snapshot": {
+ "type": "object",
+ "nullable": true
+ },
+ "configuration_diff": {
+ "type": "object",
+ "nullable": true
+ },
"force_rebuild": {
"type": "boolean"
},
@@ -13285,6 +13329,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 f2761de59..6182cacd3 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -59,7 +59,6 @@ paths:
- git_repository
- git_branch
- build_pack
- - ports_exposes
properties:
project_uuid:
type: string
@@ -81,7 +80,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
@@ -344,7 +343,6 @@ paths:
- git_repository
- git_branch
- build_pack
- - ports_exposes
properties:
project_uuid:
type: string
@@ -375,7 +373,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
@@ -632,7 +630,6 @@ paths:
- git_repository
- git_branch
- build_pack
- - ports_exposes
properties:
project_uuid:
type: string
@@ -663,7 +660,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 +932,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
@@ -1141,7 +1138,6 @@ paths:
- environment_name
- environment_uuid
- docker_registry_image_name
- - ports_exposes
properties:
project_uuid:
type: string
@@ -1337,95 +1333,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 +1475,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
@@ -2398,6 +2305,48 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/previews/{pull_request_id}':
+ delete:
+ tags:
+ - Applications
+ summary: 'Delete Preview Deployment'
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
+ operationId: delete-preview-deployment-by-pull-request-id
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ -
+ name: pull_request_id
+ in: path
+ description: 'Pull request ID of the preview to delete.'
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: 'Preview deletion queued.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/cloud-tokens:
get:
tags:
@@ -2723,8 +2672,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'
@@ -2732,8 +2681,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)'
@@ -2997,6 +2946,30 @@ paths:
mysql_conf:
type: string
description: 'MySQL conf'
+ health_check_enabled:
+ type: boolean
+ description: 'Enable the database healthcheck probe.'
+ default: true
+ health_check_interval:
+ type: integer
+ description: 'Healthcheck interval in seconds.'
+ minimum: 1
+ default: 15
+ health_check_timeout:
+ type: integer
+ description: 'Healthcheck timeout in seconds.'
+ minimum: 1
+ default: 5
+ health_check_retries:
+ type: integer
+ description: 'Healthcheck retries count.'
+ minimum: 1
+ default: 5
+ health_check_start_period:
+ type: integer
+ description: 'Healthcheck start period in seconds.'
+ minimum: 0
+ default: 5
type: object
responses:
'200':
@@ -3118,7 +3091,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
@@ -3127,7 +3100,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
@@ -5442,6 +5415,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
@@ -6692,6 +6723,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':
@@ -7874,6 +7908,7 @@ components:
description: 'Build pack.'
enum:
- nixpacks
+ - railpack
- static
- dockerfile
- dockercompose
@@ -8143,6 +8178,15 @@ components:
docker_registry_image_tag:
type: string
nullable: true
+ configuration_hash:
+ type: string
+ nullable: true
+ configuration_snapshot:
+ type: object
+ nullable: true
+ configuration_diff:
+ type: object
+ nullable: true
force_rebuild:
type: boolean
commit:
@@ -8496,6 +8540,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..8907a30b9 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.16'
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..da045fe03 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.16'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 27d911c67..9c9a405aa 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,16 +1,16 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.474"
+ "version": "4.1.2"
},
"nightly": {
- "version": "4.0.0"
+ "version": "4.2.0"
},
"helper": {
- "version": "1.0.13"
+ "version": "1.0.14"
},
"realtime": {
- "version": "1.0.13"
+ "version": "1.0.16"
},
"sentinel": {
"version": "0.0.21"
diff --git a/package-lock.json b/package-lock.json
index 1fcd7cc1e..9d495c412 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,21 +10,15 @@
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
- "ioredis": "5.6.1",
"playwright": "^1.58.2"
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
- "@vitejs/plugin-vue": "6.0.3",
- "axios": "1.15.0",
- "laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
- "postcss": "8.5.6",
- "pusher-js": "8.4.0",
+ "postcss": "8.5.15",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
- "vite": "7.3.2",
- "vue": "3.5.26"
+ "vite": "7.3.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -40,56 +34,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/@babel/helper-string-parser": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
- "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/helper-validator-identifier": {
- "version": "7.28.5",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
- "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=6.9.0"
- }
- },
- "node_modules/@babel/parser": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
- "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/types": "^7.29.0"
- },
- "bin": {
- "parser": "bin/babel-parser.js"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
- "node_modules/@babel/types": {
- "version": "7.29.0",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
- "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.28.5"
- },
- "engines": {
- "node": ">=6.9.0"
- }
- },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -532,12 +476,6 @@
"node": ">=18"
}
},
- "node_modules/@ioredis/commands": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
- "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==",
- "license": "MIT"
- },
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -588,13 +526,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.53",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz",
- "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -945,14 +876,6 @@
"win32"
]
},
- "node_modules/@socket.io/component-emitter": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
- "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
- "dev": true,
- "license": "MIT",
- "peer": true
- },
"node_modules/@tailwindcss/forms": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
@@ -1325,132 +1248,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/@vitejs/plugin-vue": {
- "version": "6.0.3",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz",
- "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@rolldown/pluginutils": "1.0.0-beta.53"
- },
- "engines": {
- "node": "^20.19.0 || >=22.12.0"
- },
- "peerDependencies": {
- "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
- "vue": "^3.2.25"
- }
- },
- "node_modules/@vue/compiler-core": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
- "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/shared": "3.5.26",
- "entities": "^7.0.0",
- "estree-walker": "^2.0.2",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-dom": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
- "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-core": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/compiler-sfc": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
- "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@babel/parser": "^7.28.5",
- "@vue/compiler-core": "3.5.26",
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26",
- "estree-walker": "^2.0.2",
- "magic-string": "^0.30.21",
- "postcss": "^8.5.6",
- "source-map-js": "^1.2.1"
- }
- },
- "node_modules/@vue/compiler-ssr": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
- "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/reactivity": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
- "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-core": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
- "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/shared": "3.5.26"
- }
- },
- "node_modules/@vue/runtime-dom": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
- "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/reactivity": "3.5.26",
- "@vue/runtime-core": "3.5.26",
- "@vue/shared": "3.5.26",
- "csstype": "^3.2.3"
- }
- },
- "node_modules/@vue/server-renderer": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
- "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-ssr": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "vue": "3.5.26"
- }
- },
- "node_modules/@vue/shared": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
- "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -1466,39 +1263,6 @@
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
- "node_modules/asynckit": {
- "version": "0.4.0",
- "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/axios": {
- "version": "1.15.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
- "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
- "proxy-from-env": "^2.1.0"
- }
- },
- "node_modules/call-bind-apply-helpers": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1509,28 +1273,6 @@
"node": ">=6"
}
},
- "node_modules/cluster-key-slot": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
- "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=0.10.0"
- }
- },
- "node_modules/combined-stream": {
- "version": "1.0.8",
- "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "delayed-stream": "~1.0.0"
- },
- "engines": {
- "node": ">= 0.8"
- }
- },
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1543,49 +1285,6 @@
"node": ">=4"
}
},
- "node_modules/csstype": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
- "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/debug": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
- "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
- "license": "MIT",
- "dependencies": {
- "ms": "^2.1.3"
- },
- "engines": {
- "node": ">=6.0"
- },
- "peerDependenciesMeta": {
- "supports-color": {
- "optional": true
- }
- }
- },
- "node_modules/delayed-stream": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=0.4.0"
- }
- },
- "node_modules/denque": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
- "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
- "license": "Apache-2.0",
- "engines": {
- "node": ">=0.10"
- }
- },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1596,47 +1295,6 @@
"node": ">=8"
}
},
- "node_modules/dunder-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.1",
- "es-errors": "^1.3.0",
- "gopd": "^1.2.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/engine.io-client": {
- "version": "6.6.4",
- "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
- "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.4.1",
- "engine.io-parser": "~5.2.1",
- "ws": "~8.18.3",
- "xmlhttprequest-ssl": "~2.1.1"
- }
- },
- "node_modules/engine.io-parser": {
- "version": "5.2.3",
- "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
- "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/enhanced-resolve": {
"version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -1651,68 +1309,6 @@
"node": ">=10.13.0"
}
},
- "node_modules/entities": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
- "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=0.12"
- },
- "funding": {
- "url": "https://github.com/fb55/entities?sponsor=1"
- }
- },
- "node_modules/es-define-property": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-errors": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-object-atoms": {
- "version": "1.1.1",
- "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/es-set-tostringtag": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "es-errors": "^1.3.0",
- "get-intrinsic": "^1.2.6",
- "has-tostringtag": "^1.0.2",
- "hasown": "^2.0.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
"node_modules/esbuild": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
@@ -1755,13 +1351,6 @@
"@esbuild/win32-x64": "0.27.2"
}
},
- "node_modules/estree-walker": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
- "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1780,44 +1369,6 @@
}
}
},
- "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==",
- "dev": true,
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
- "node_modules/form-data": {
- "version": "4.0.5",
- "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "asynckit": "^0.4.0",
- "combined-stream": "^1.0.8",
- "es-set-tostringtag": "^2.1.0",
- "hasown": "^2.0.2",
- "mime-types": "^2.1.12"
- },
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1833,68 +1384,6 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
- "node_modules/function-bind": {
- "version": "1.1.2",
- "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-intrinsic": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "call-bind-apply-helpers": "^1.0.2",
- "es-define-property": "^1.0.1",
- "es-errors": "^1.3.0",
- "es-object-atoms": "^1.1.1",
- "function-bind": "^1.1.2",
- "get-proto": "^1.0.1",
- "gopd": "^1.2.0",
- "has-symbols": "^1.1.0",
- "hasown": "^2.0.2",
- "math-intrinsics": "^1.1.0"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/get-proto": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "dunder-proto": "^1.0.1",
- "es-object-atoms": "^1.0.0"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/gopd": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -1902,72 +1391,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/has-symbols": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/has-tostringtag": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "has-symbols": "^1.0.3"
- },
- "engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
- }
- },
- "node_modules/hasown": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
- "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "function-bind": "^1.1.2"
- },
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/ioredis": {
- "version": "5.6.1",
- "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
- "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
- "license": "MIT",
- "dependencies": {
- "@ioredis/commands": "^1.1.1",
- "cluster-key-slot": "^1.1.0",
- "debug": "^4.3.4",
- "denque": "^2.1.0",
- "lodash.defaults": "^4.2.0",
- "lodash.isarguments": "^3.1.0",
- "redis-errors": "^1.2.0",
- "redis-parser": "^3.0.0",
- "standard-as-callback": "^2.1.0"
- },
- "engines": {
- "node": ">=12.22.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/ioredis"
- }
- },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1978,20 +1401,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
- "node_modules/laravel-echo": {
- "version": "2.2.7",
- "resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.2.7.tgz",
- "integrity": "sha512-MgD3ZFXqH5OOVdRjxNHPyQ0ijRr5+nLr7MtyF2XP+kRfhl+Qaa7qVzbtCn1HMgXuTn4SWH6ivn4qWVLlvRl8kg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=20"
- },
- "peerDependencies": {
- "pusher-js": "*",
- "socket.io-client": "*"
- }
- },
"node_modules/laravel-vite-plugin": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.1.tgz",
@@ -2279,18 +1688,6 @@
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
"license": "MIT"
},
- "node_modules/lodash.defaults": {
- "version": "4.2.0",
- "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
- "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
- "license": "MIT"
- },
- "node_modules/lodash.isarguments": {
- "version": "3.1.0",
- "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
- "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
- "license": "MIT"
- },
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
@@ -2313,39 +1710,6 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
- "node_modules/math-intrinsics": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.4"
- }
- },
- "node_modules/mime-db": {
- "version": "1.52.0",
- "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">= 0.6"
- }
- },
- "node_modules/mime-types": {
- "version": "2.1.35",
- "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "mime-db": "1.52.0"
- },
- "engines": {
- "node": ">= 0.6"
- }
- },
"node_modules/mini-svg-data-uri": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
@@ -2355,16 +1719,10 @@
"mini-svg-data-uri": "cli.js"
}
},
- "node_modules/ms": {
- "version": "2.1.3",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
- "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "license": "MIT"
- },
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"dev": true,
"funding": [
{
@@ -2445,9 +1803,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.6",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
- "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
@@ -2465,7 +1823,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -2500,26 +1858,6 @@
"react": ">=16.0.0"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/pusher-js": {
- "version": "8.4.0",
- "resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
- "integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "tweetnacl": "^1.0.3"
- }
- },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -2531,27 +1869,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/redis-errors": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
- "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
- "license": "MIT",
- "engines": {
- "node": ">=4"
- }
- },
- "node_modules/redis-parser": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
- "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
- "license": "MIT",
- "dependencies": {
- "redis-errors": "^1.0.0"
- },
- "engines": {
- "node": ">=4"
- }
- },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2597,38 +1914,6 @@
"fsevents": "~2.3.2"
}
},
- "node_modules/socket.io-client": {
- "version": "4.8.3",
- "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
- "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.4.1",
- "engine.io-client": "~6.6.1",
- "socket.io-parser": "~4.2.4"
- },
- "engines": {
- "node": ">=10.0.0"
- }
- },
- "node_modules/socket.io-parser": {
- "version": "4.2.5",
- "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
- "integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "dependencies": {
- "@socket.io/component-emitter": "~3.1.0",
- "debug": "~4.4.1"
- },
- "engines": {
- "node": ">=10.0.0"
- }
- },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2639,12 +1924,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/standard-as-callback": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
- "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
- "license": "MIT"
- },
"node_modules/tailwind-scrollbar": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-4.0.2.tgz",
@@ -2698,13 +1977,6 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
- "node_modules/tweetnacl": {
- "version": "1.0.3",
- "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
- "integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
- "dev": true,
- "license": "Unlicense"
- },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -2809,61 +2081,6 @@
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
- },
- "node_modules/vue": {
- "version": "3.5.26",
- "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
- "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@vue/compiler-dom": "3.5.26",
- "@vue/compiler-sfc": "3.5.26",
- "@vue/runtime-dom": "3.5.26",
- "@vue/server-renderer": "3.5.26",
- "@vue/shared": "3.5.26"
- },
- "peerDependencies": {
- "typescript": "*"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
- "dev": true,
- "license": "MIT",
- "peer": true,
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/xmlhttprequest-ssl": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
- "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
- "dev": true,
- "peer": true,
- "engines": {
- "node": ">=0.4.0"
- }
}
}
}
diff --git a/package.json b/package.json
index 3afefa833..c3fb1bc5f 100644
--- a/package.json
+++ b/package.json
@@ -8,23 +8,17 @@
},
"devDependencies": {
"@tailwindcss/postcss": "4.1.18",
- "@vitejs/plugin-vue": "6.0.3",
- "axios": "1.15.0",
- "laravel-echo": "2.2.7",
"laravel-vite-plugin": "2.0.1",
- "postcss": "8.5.6",
- "pusher-js": "8.4.0",
+ "postcss": "8.5.15",
"tailwind-scrollbar": "4.0.2",
"tailwindcss": "4.1.18",
- "vite": "7.3.2",
- "vue": "3.5.26"
+ "vite": "7.3.2"
},
"dependencies": {
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@xterm/addon-fit": "0.10.0",
"@xterm/xterm": "5.5.0",
- "ioredis": "5.6.1",
"playwright": "^1.58.2"
}
}
diff --git a/public/js/echo.js b/public/js/echo.js
index 971662063..22f280301 100644
--- a/public/js/echo.js
+++ b/public/js/echo.js
@@ -1,2 +1,2 @@
-// Source: https://cdnjs.cloudflare.com/ajax/libs/laravel-echo/1.15.3/echo.iife.min.js
-var Echo=function(){"use strict";function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function o(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function i(e,t){for(var n=0;n{var s;e.startsWith("pusher:")||(s=String(this.options.namespace??"").replace(/\./g,"\\"),s=e.startsWith(s)?e.substring(s.length+1):"."+e,n(s,t))}),this}stopListening(e,t){return t?this.subscription.unbind(this.eventFormatter.format(e),t):this.subscription.unbind(this.eventFormatter.format(e)),this}stopListeningToAll(e){return e?this.subscription.unbind_global(e):this.subscription.unbind_global(),this}subscribed(e){return this.on("pusher:subscription_succeeded",()=>{e()}),this}error(t){return this.on("pusher:subscription_error",e=>{t(e)}),this}on(e,t){return this.subscription.bind(e,t),this}}class i extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class r extends s{whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}}class o extends i{here(e){return this.on("pusher:subscription_succeeded",t=>{e(Object.keys(t.members).map(e=>t.members[e]))}),this}joining(t){return this.on("pusher:member_added",e=>{t(e.info)}),this}whisper(e,t){return this.pusher.channels.channels[this.name].trigger("client-"+e,t),this}leaving(t){return this.on("pusher:member_removed",e=>{t(e.info)}),this}}class h extends t{constructor(e,t,s){super(),this.events={},this.listeners={},this.name=t,this.socket=e,this.options=s,this.eventFormatter=new n(this.options.namespace),this.subscribe()}subscribe(){this.socket.emit("subscribe",{channel:this.name,auth:this.options.auth||{}})}unsubscribe(){this.unbind(),this.socket.emit("unsubscribe",{channel:this.name,auth:this.options.auth||{}})}listen(e,t){return this.on(this.eventFormatter.format(e),t),this}stopListening(e,t){return this.unbindEvent(this.eventFormatter.format(e),t),this}subscribed(t){return this.on("connect",e=>{t(e)}),this}error(e){return this}on(s,e){return this.listeners[s]=this.listeners[s]||[],this.events[s]||(this.events[s]=(e,t)=>{this.name===e&&this.listeners[s]&&this.listeners[s].forEach(e=>e(t))},this.socket.on(s,this.events[s])),this.listeners[s].push(e),this}unbind(){Object.keys(this.events).forEach(e=>{this.unbindEvent(e)})}unbindEvent(e,t){this.listeners[e]=this.listeners[e]||[],t&&(this.listeners[e]=this.listeners[e].filter(e=>e!==t)),t&&0!==this.listeners[e].length||(this.events[e]&&(this.socket.removeListener(e,this.events[e]),delete this.events[e]),delete this.listeners[e])}}class c extends h{whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}}class a extends c{here(t){return this.on("presence:subscribed",e=>{t(e.map(e=>e.user_info))}),this}joining(t){return this.on("presence:joining",e=>t(e.user_info)),this}whisper(e,t){return this.socket.emit("client event",{channel:this.name,event:"client-"+e,data:t}),this}leaving(t){return this.on("presence:leaving",e=>t(e.user_info)),this}}class u extends t{subscribe(){}unsubscribe(){}listen(e,t){return this}listenToAll(e){return this}stopListening(e,t){return this}subscribed(e){return this}error(e){return this}on(e,t){return this}}class l extends u{whisper(e,t){return this}}class p extends u{whisper(e,t){return this}}class d extends l{here(e){return this}joining(e){return this}whisper(e,t){return this}leaving(e){return this}}const b=class b{constructor(e){this.setOptions(e),this.connect()}setOptions(e){this.options={...b._defaultOptions,...e,broadcaster:e.broadcaster};let t=this.csrfToken();t&&(this.options.auth.headers["X-CSRF-TOKEN"]=t,this.options.userAuthentication.headers["X-CSRF-TOKEN"]=t),(t=this.options.bearerToken)&&(this.options.auth.headers.Authorization="Bearer "+t,this.options.userAuthentication.headers.Authorization="Bearer "+t)}csrfToken(){var e;return typeof window<"u"&&null!=(e=window.Laravel)&&e.csrfToken?window.Laravel.csrfToken:this.options.csrfToken||(typeof document<"u"&&"function"==typeof document.querySelector?(null==(e=document.querySelector('meta[name="csrf-token"]'))?void 0:e.getAttribute("content"))??null:null)}};b._defaultOptions={auth:{headers:{}},authEndpoint:"/broadcasting/auth",userAuthentication:{endpoint:"/broadcasting/user-auth",headers:{}},csrfToken:null,bearerToken:null,host:null,key:null,namespace:"App.Events"};var v=b;class f extends v{constructor(){super(...arguments),this.channels={}}connect(){if(typeof this.options.client<"u")this.pusher=this.options.client;else if(this.options.Pusher)this.pusher=new this.options.Pusher(this.options.key,this.options);else{if(!(typeof window<"u"&&typeof window.Pusher<"u"))throw new Error("Pusher client not found. Should be globally available or passed via options.client");this.pusher=new window.Pusher(this.options.key,this.options)}}signin(){this.pusher.signin()}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new s(this.pusher,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new i(this.pusher,"private-"+e,this.options)),this.channels["private-"+e]}encryptedPrivateChannel(e){return this.channels["private-encrypted-"+e]||(this.channels["private-encrypted-"+e]=new r(this.pusher,"private-encrypted-"+e,this.options)),this.channels["private-encrypted-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new o(this.pusher,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"private-encrypted-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.pusher.connection.socket_id}disconnect(){this.pusher.disconnect()}}class m extends v{constructor(){super(...arguments),this.channels={}}connect(){let e=this.getSocketIO();this.socket=e(this.options.host??void 0,this.options),this.socket.io.on("reconnect",()=>{Object.values(this.channels).forEach(e=>{e.subscribe()})})}getSocketIO(){if(typeof this.options.client<"u")return this.options.client;if(typeof window<"u"&&typeof window.io<"u")return window.io;throw new Error("Socket.io client not found. Should be globally available or passed via options.client")}listen(e,t,s){return this.channel(e).listen(t,s)}channel(e){return this.channels[e]||(this.channels[e]=new h(this.socket,e,this.options)),this.channels[e]}privateChannel(e){return this.channels["private-"+e]||(this.channels["private-"+e]=new c(this.socket,"private-"+e,this.options)),this.channels["private-"+e]}presenceChannel(e){return this.channels["presence-"+e]||(this.channels["presence-"+e]=new a(this.socket,"presence-"+e,this.options)),this.channels["presence-"+e]}leave(e){[e,"private-"+e,"presence-"+e].forEach(e=>{this.leaveChannel(e)})}leaveChannel(e){this.channels[e]&&(this.channels[e].unsubscribe(),delete this.channels[e])}socketId(){return this.socket.id}disconnect(){this.socket.disconnect()}}class w extends v{constructor(){super(...arguments),this.channels={}}connect(){}listen(e,t,s){return new u}channel(e){return new u}privateChannel(e){return new l}encryptedPrivateChannel(e){return new p}presenceChannel(e){return new d}leave(e){}leaveChannel(e){}socketId(){return"fake-socket-id"}disconnect(){}}return e.Channel=t,e.Connector=v,e.EventFormatter=n,e.default=class{constructor(e){this.options=e,this.connect(),this.options.withoutInterceptors||this.registerInterceptors()}channel(e){return this.connector.channel(e)}connect(){if("reverb"===this.options.broadcaster)this.connector=new f({...this.options,cluster:""});else if("pusher"===this.options.broadcaster)this.connector=new f(this.options);else if("ably"===this.options.broadcaster)this.connector=new f({...this.options,cluster:"",broadcaster:"pusher"});else if("socket.io"===this.options.broadcaster)this.connector=new m(this.options);else if("null"===this.options.broadcaster)this.connector=new w(this.options);else{if("function"!=typeof this.options.broadcaster||!function(e){try{new e}catch(e){if(e instanceof Error&&e.message.includes("is not a constructor"))return}return 1}(this.options.broadcaster))throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} is not supported.`);this.connector=new this.options.broadcaster(this.options)}}disconnect(){this.connector.disconnect()}join(e){return this.connector.presenceChannel(e)}leave(e){this.connector.leave(e)}leaveChannel(e){this.connector.leaveChannel(e)}leaveAllChannels(){for(const e in this.connector.channels)this.leaveChannel(e)}listen(e,t,s){return this.connector.listen(e,t,s)}private(e){return this.connector.privateChannel(e)}encryptedPrivate(e){if(this.connectorSupportsEncryptedPrivateChannels(this.connector))return this.connector.encryptedPrivateChannel(e);throw new Error(`Broadcaster ${typeof this.options.broadcaster} ${String(this.options.broadcaster)} does not support encrypted private channels.`)}connectorSupportsEncryptedPrivateChannels(e){return e instanceof f||e instanceof w}socketId(){return this.connector.socketId()}registerInterceptors(){typeof Vue<"u"&&null!=Vue&&Vue.http&&this.registerVueRequestInterceptor(),"function"==typeof axios&&this.registerAxiosRequestInterceptor(),"function"==typeof jQuery&&this.registerjQueryAjaxSetup(),"object"==typeof Turbo&&this.registerTurboRequestInterceptor()}registerVueRequestInterceptor(){Vue.http.interceptors.push((e,t)=>{this.socketId()&&e.headers.set("X-Socket-ID",this.socketId()),t()})}registerAxiosRequestInterceptor(){axios.interceptors.request.use(e=>(this.socketId()&&(e.headers["X-Socket-Id"]=this.socketId()),e))}registerjQueryAjaxSetup(){typeof jQuery.ajax<"u"&&jQuery.ajaxPrefilter((e,t,s)=>{this.socketId()&&s.setRequestHeader("X-Socket-Id",this.socketId())})}registerTurboRequestInterceptor(){document.addEventListener("turbo:before-fetch-request",e=>{e.detail.fetchOptions.headers["X-Socket-Id"]=this.socketId()})}},Object.defineProperties(e,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}}),e}({});
diff --git a/public/js/pusher.js b/public/js/pusher.js
index f18c77a4c..862e89bc0 100644
--- a/public/js/pusher.js
+++ b/public/js/pusher.js
@@ -1,10 +1,9 @@
/*!
- * Pusher JavaScript Library v8.3.0
+ * Pusher JavaScript Library v8.4.0
* https://pusher.com/
- *
+ * https://cdnjs.cloudflare.com/ajax/libs/pusher/8.4.0/pusher.min.js
* Copyright 2020, Pusher
* Released under the MIT licence.
*/
-// Source: https://cdnjs.cloudflare.com/ajax/libs/pusher/8.3.0/pusher.min.js
-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.3.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])}));
+!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Pusher=e():t.Pusher=e()}(window,(function(){return function(t){var e={};function n(i){if(e[i])return e[i].exports;var r=e[i]={i:i,l:!1,exports:{}};return t[i].call(r.exports,r,r.exports,n),r.l=!0,r.exports}return n.m=t,n.c=e,n.d=function(t,e,i){n.o(t,e)||Object.defineProperty(t,e,{enumerable:!0,get:i})},n.r=function(t){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})},n.t=function(t,e){if(1&e&&(t=n(t)),8&e)return t;if(4&e&&"object"==typeof t&&t&&t.__esModule)return t;var i=Object.create(null);if(n.r(i),Object.defineProperty(i,"default",{enumerable:!0,value:t}),2&e&&"string"!=typeof t)for(var r in t)n.d(i,r,function(e){return t[e]}.bind(null,r));return i},n.n=function(t){var e=t&&t.__esModule?function(){return t.default}:function(){return t};return n.d(e,"a",e),e},n.o=function(t,e){return Object.prototype.hasOwnProperty.call(t,e)},n.p="",n(n.s=2)}([function(t,e,n){"use strict";var i,r=this&&this.__extends||(i=function(t,e){return(i=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(t,e){t.__proto__=e}||function(t,e){for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n])})(t,e)},function(t,e){function n(){this.constructor=t}i(t,e),t.prototype=null===e?Object.create(e):(n.prototype=e.prototype,new n)});Object.defineProperty(e,"__esModule",{value:!0});var s=function(){function t(t){void 0===t&&(t="="),this._paddingCharacter=t}return t.prototype.encodedLength=function(t){return this._paddingCharacter?(t+2)/3*4|0:(8*t+5)/6|0},t.prototype.encode=function(t){for(var e="",n=0;n>>18&63),e+=this._encodeByte(i>>>12&63),e+=this._encodeByte(i>>>6&63),e+=this._encodeByte(i>>>0&63)}var r=t.length-n;if(r>0){i=t[n]<<16|(2===r?t[n+1]<<8:0);e+=this._encodeByte(i>>>18&63),e+=this._encodeByte(i>>>12&63),e+=2===r?this._encodeByte(i>>>6&63):this._paddingCharacter||"",e+=this._paddingCharacter||""}return e},t.prototype.maxDecodedLength=function(t){return this._paddingCharacter?t/4*3|0:(6*t+7)/8|0},t.prototype.decodedLength=function(t){return this.maxDecodedLength(t.length-this._getPaddingLength(t))},t.prototype.decode=function(t){if(0===t.length)return new Uint8Array(0);for(var e=this._getPaddingLength(t),n=t.length-e,i=new Uint8Array(this.maxDecodedLength(n)),r=0,s=0,o=0,a=0,c=0,h=0,u=0;s>>4,i[r++]=c<<4|h>>>2,i[r++]=h<<6|u,o|=256&a,o|=256&c,o|=256&h,o|=256&u;if(s>>4,o|=256&a,o|=256&c),s>>2,o|=256&h),s>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-15,e+=62-t>>>8&3,String.fromCharCode(e)},t.prototype._decodeChar=function(t){var e=256;return e+=(42-t&t-44)>>>8&-256+t-43+62,e+=(46-t&t-48)>>>8&-256+t-47+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},t.prototype._getPaddingLength=function(t){var e=0;if(this._paddingCharacter){for(var n=t.length-1;n>=0&&t[n]===this._paddingCharacter;n--)e++;if(t.length<4||e>2)throw new Error("Base64Coder: incorrect padding")}return e},t}();e.Coder=s;var o=new s;e.encode=function(t){return o.encode(t)},e.decode=function(t){return o.decode(t)};var a=function(t){function e(){return null!==t&&t.apply(this,arguments)||this}return r(e,t),e.prototype._encodeByte=function(t){var e=t;return e+=65,e+=25-t>>>8&6,e+=51-t>>>8&-75,e+=61-t>>>8&-13,e+=62-t>>>8&49,String.fromCharCode(e)},e.prototype._decodeChar=function(t){var e=256;return e+=(44-t&t-46)>>>8&-256+t-45+62,e+=(94-t&t-96)>>>8&-256+t-95+63,e+=(47-t&t-58)>>>8&-256+t-48+52,e+=(64-t&t-91)>>>8&-256+t-65+0,e+=(96-t&t-123)>>>8&-256+t-97+26},e}(s);e.URLSafeCoder=a;var c=new a;e.encodeURLSafe=function(t){return c.encode(t)},e.decodeURLSafe=function(t){return c.decode(t)},e.encodedLength=function(t){return o.encodedLength(t)},e.maxDecodedLength=function(t){return o.maxDecodedLength(t)},e.decodedLength=function(t){return o.decodedLength(t)}},function(t,e,n){"use strict";Object.defineProperty(e,"__esModule",{value:!0});var i="utf8: invalid source encoding";function r(t){for(var e=0,n=0;n=t.length-1)throw new Error("utf8: invalid string");n++,e+=4}}return e}e.encode=function(t){for(var e=new Uint8Array(r(t)),n=0,i=0;i>6,e[n++]=128|63&s):s<55296?(e[n++]=224|s>>12,e[n++]=128|s>>6&63,e[n++]=128|63&s):(i++,s=(1023&s)<<10,s|=1023&t.charCodeAt(i),s+=65536,e[n++]=240|s>>18,e[n++]=128|s>>12&63,e[n++]=128|s>>6&63,e[n++]=128|63&s)}return e},e.encodedLength=r,e.decode=function(t){for(var e=[],n=0;n=t.length)throw new Error(i);if(128!=(192&(o=t[++n])))throw new Error(i);r=(31&r)<<6|63&o,s=128}else if(r<240){if(n>=t.length-1)throw new Error(i);var o=t[++n],a=t[++n];if(128!=(192&o)||128!=(192&a))throw new Error(i);r=(15&r)<<12|(63&o)<<6|63&a,s=2048}else{if(!(r<248))throw new Error(i);if(n>=t.length-2)throw new Error(i);o=t[++n],a=t[++n];var c=t[++n];if(128!=(192&o)||128!=(192&a)||128!=(192&c))throw new Error(i);r=(15&r)<<18|(63&o)<<12|(63&a)<<6|63&c,s=65536}if(r=55296&&r<=57343)throw new Error(i);if(r>=65536){if(r>1114111)throw new Error(i);r-=65536,e.push(String.fromCharCode(55296|r>>10)),r=56320|1023&r}}e.push(String.fromCharCode(r))}return e.join("")}},function(t,e,n){t.exports=n(3).default},function(t,e,n){"use strict";n.r(e);class i{constructor(t,e){this.lastId=0,this.prefix=t,this.name=e}create(t){this.lastId++;var e=this.lastId,n=this.prefix+e,i=this.name+"["+e+"]",r=!1,s=function(){r||(t.apply(null,arguments),r=!0)};return this[e]=s,{number:e,id:n,name:i,callback:s}}remove(t){delete this[t.number]}}var r=new i("_pusher_script_","Pusher.ScriptReceivers"),s={VERSION:"8.4.0",PROTOCOL:7,wsPort:80,wssPort:443,wsPath:"",httpHost:"sockjs.pusher.com",httpPort:80,httpsPort:443,httpPath:"/pusher",stats_host:"stats.pusher.com",authEndpoint:"/pusher/auth",authTransport:"ajax",activityTimeout:12e4,pongTimeout:3e4,unavailableTimeout:1e4,userAuthentication:{endpoint:"/pusher/user-auth",transport:"ajax"},channelAuthorization:{endpoint:"/pusher/auth",transport:"ajax"},cdn_http:"http://js.pusher.com",cdn_https:"https://js.pusher.com",dependency_suffix:""};var o=new i("_pusher_dependencies","Pusher.DependenciesReceivers"),a=new class{constructor(t){this.options=t,this.receivers=t.receivers||r,this.loading={}}load(t,e,n){var i=this;if(i.loading[t]&&i.loading[t].length>0)i.loading[t].push(n);else{i.loading[t]=[n];var r=ue.createScriptRequest(i.getPath(t,e)),s=i.receivers.create((function(e){if(i.receivers.remove(s),i.loading[t]){var n=i.loading[t];delete i.loading[t];for(var o=function(t){t||r.cleanup()},a=0;a>>6)+S(128|63&e):S(224|e>>>12&15)+S(128|e>>>6&63)+S(128|63&e)},E=function(t){return t.replace(/[^\x00-\x7F]/g,P)},O=function(t){var e=[0,2,1][t.length%3],n=t.charCodeAt(0)<<16|(t.length>1?t.charCodeAt(1):0)<<8|(t.length>2?t.charCodeAt(2):0);return[_.charAt(n>>>18),_.charAt(n>>>12&63),e>=2?"=":_.charAt(n>>>6&63),e>=1?"=":_.charAt(63&n)].join("")},x=window.btoa||function(t){return t.replace(/[\s\S]{1,3}/g,O)};var L=class{constructor(t,e,n,i){this.clear=e,this.timer=t(()=>{this.timer&&(this.timer=i(this.timer))},n)}isRunning(){return null!==this.timer}ensureAborted(){this.timer&&(this.clear(this.timer),this.timer=null)}};function A(t){window.clearTimeout(t)}function R(t){window.clearInterval(t)}class I extends L{constructor(t,e){super(setTimeout,A,t,(function(t){return e(),null}))}}class D extends L{constructor(t,e){super(setInterval,R,t,(function(t){return e(),t}))}}var j={now:()=>Date.now?Date.now():(new Date).valueOf(),defer:t=>new I(0,t),method(t,...e){var n=Array.prototype.slice.call(arguments,1);return function(e){return e[t].apply(e,n.concat(arguments))}}};function N(t,...e){for(var n=0;n{window.console&&window.console.log&&window.console.log(t)}}debug(...t){this.log(this.globalLog,t)}warn(...t){this.log(this.globalLogWarn,t)}error(...t){this.log(this.globalLogError,t)}globalLogWarn(t){window.console&&window.console.warn?window.console.warn(t):this.globalLog(t)}globalLogError(t){window.console&&window.console.error?window.console.error(t):this.globalLogWarn(t)}log(t,...e){var n=H.apply(this,arguments);if(Le.log)Le.log(n);else if(Le.logToConsole){t.bind(this)(n)}}},Y=function(t,e,n,i,r){void 0===n.headers&&null==n.headersProvider||V.warn(`To send headers with the ${i.toString()} request, you must use AJAX, rather than JSONP.`);var s=t.nextAuthCallbackID.toString();t.nextAuthCallbackID++;var o=t.getDocument(),a=o.createElement("script");t.auth_callbacks[s]=function(t){r(null,t)};var c="Pusher.auth_callbacks['"+s+"']";a.src=n.endpoint+"?callback="+encodeURIComponent(c)+"&"+e;var h=o.getElementsByTagName("head")[0]||o.documentElement;h.insertBefore(a,h.firstChild)};class Q{constructor(t){this.src=t}send(t){var e=this,n="Error loading "+e.src;e.script=document.createElement("script"),e.script.id=t.id,e.script.src=e.src,e.script.type="text/javascript",e.script.charset="UTF-8",e.script.addEventListener?(e.script.onerror=function(){t.callback(n)},e.script.onload=function(){t.callback(null)}):e.script.onreadystatechange=function(){"loaded"!==e.script.readyState&&"complete"!==e.script.readyState||t.callback(null)},void 0===e.script.async&&document.attachEvent&&/opera/i.test(navigator.userAgent)?(e.errorScript=document.createElement("script"),e.errorScript.id=t.id+"_error",e.errorScript.text=t.name+"('"+n+"');",e.script.async=e.errorScript.async=!1):e.script.async=!0;var i=document.getElementsByTagName("head")[0];i.insertBefore(e.script,i.firstChild),e.errorScript&&i.insertBefore(e.errorScript,e.script.nextSibling)}cleanup(){this.script&&(this.script.onload=this.script.onerror=null,this.script.onreadystatechange=null),this.script&&this.script.parentNode&&this.script.parentNode.removeChild(this.script),this.errorScript&&this.errorScript.parentNode&&this.errorScript.parentNode.removeChild(this.errorScript),this.script=null,this.errorScript=null}}class K{constructor(t,e){this.url=t,this.data=e}send(t){if(!this.request){var e=W(this.data),n=this.url+"/"+t.number+"?"+e;this.request=ue.createScriptRequest(n),this.request.send(t)}}cleanup(){this.request&&this.request.cleanup()}}var Z={name:"jsonp",getAgent:function(t,e){return function(n,i){var s="http"+(e?"s":"")+"://"+(t.host||t.options.host)+t.options.path,o=ue.createJSONPRequest(s,n),a=ue.ScriptReceivers.create((function(e,n){r.remove(a),o.cleanup(),n&&n.host&&(t.host=n.host),i&&i(e,n)}));o.send(a)}}};function tt(t,e,n){return t+(e.useTLS?"s":"")+"://"+(e.useTLS?e.hostTLS:e.hostNonTLS)+n}function et(t,e){return"/app/"+t+("?protocol="+s.PROTOCOL+"&client=js&version="+s.VERSION+(e?"&"+e:""))}var nt={getInitial:function(t,e){return tt("ws",e,(e.httpPath||"")+et(t,"flash=false"))}},it={getInitial:function(t,e){return tt("http",e,(e.httpPath||"/pusher")+et(t))}},rt={getInitial:function(t,e){return tt("http",e,e.httpPath||"/pusher")},getPath:function(t,e){return et(t)}};class st{constructor(){this._callbacks={}}get(t){return this._callbacks[ot(t)]}add(t,e,n){var i=ot(t);this._callbacks[i]=this._callbacks[i]||[],this._callbacks[i].push({fn:e,context:n})}remove(t,e,n){if(t||e||n){var i=t?[ot(t)]:z(this._callbacks);e||n?this.removeCallback(i,e,n):this.removeAllCallbacks(i)}else this._callbacks={}}removeCallback(t,e,n){q(t,(function(t){this._callbacks[t]=F(this._callbacks[t]||[],(function(t){return e&&e!==t.fn||n&&n!==t.context})),0===this._callbacks[t].length&&delete this._callbacks[t]}),this)}removeAllCallbacks(t){q(t,(function(t){delete this._callbacks[t]}),this)}}function ot(t){return"_"+t}class at{constructor(t){this.callbacks=new st,this.global_callbacks=[],this.failThrough=t}bind(t,e,n){return this.callbacks.add(t,e,n),this}bind_global(t){return this.global_callbacks.push(t),this}unbind(t,e,n){return this.callbacks.remove(t,e,n),this}unbind_global(t){return t?(this.global_callbacks=F(this.global_callbacks||[],e=>e!==t),this):(this.global_callbacks=[],this)}unbind_all(){return this.unbind(),this.unbind_global(),this}emit(t,e,n){for(var i=0;i0)for(i=0;i{this.onError(t),this.changeState("closed")}),!1}return this.bindListeners(),V.debug("Connecting",{transport:this.name,url:t}),this.changeState("connecting"),!0}close(){return!!this.socket&&(this.socket.close(),!0)}send(t){return"open"===this.state&&(j.defer(()=>{this.socket&&this.socket.send(t)}),!0)}ping(){"open"===this.state&&this.supportsPing()&&this.socket.ping()}onOpen(){this.hooks.beforeOpen&&this.hooks.beforeOpen(this.socket,this.hooks.urls.getPath(this.key,this.options)),this.changeState("open"),this.socket.onopen=void 0}onError(t){this.emit("error",{type:"WebSocketError",error:t}),this.timeline.error(this.buildTimelineMessage({error:t.toString()}))}onClose(t){t?this.changeState("closed",{code:t.code,reason:t.reason,wasClean:t.wasClean}):this.changeState("closed"),this.unbindListeners(),this.socket=void 0}onMessage(t){this.emit("message",t)}onActivity(){this.emit("activity")}bindListeners(){this.socket.onopen=()=>{this.onOpen()},this.socket.onerror=t=>{this.onError(t)},this.socket.onclose=t=>{this.onClose(t)},this.socket.onmessage=t=>{this.onMessage(t)},this.supportsPing()&&(this.socket.onactivity=()=>{this.onActivity()})}unbindListeners(){this.socket&&(this.socket.onopen=void 0,this.socket.onerror=void 0,this.socket.onclose=void 0,this.socket.onmessage=void 0,this.supportsPing()&&(this.socket.onactivity=void 0))}changeState(t,e){this.state=t,this.timeline.info(this.buildTimelineMessage({state:t,params:e})),this.emit(t,e)}buildTimelineMessage(t){return N({cid:this.id},t)}}class ht{constructor(t){this.hooks=t}isSupported(t){return this.hooks.isSupported(t)}createConnection(t,e,n,i){return new ct(this.hooks,t,e,n,i)}}var ut=new ht({urls:nt,handlesActivityChecks:!1,supportsPing:!1,isInitialized:function(){return Boolean(ue.getWebSocketAPI())},isSupported:function(){return Boolean(ue.getWebSocketAPI())},getSocket:function(t){return ue.createWebSocket(t)}}),lt={urls:it,handlesActivityChecks:!1,supportsPing:!0,isInitialized:function(){return!0}},dt=N({getSocket:function(t){return ue.HTTPFactory.createStreamingSocket(t)}},lt),pt=N({getSocket:function(t){return ue.HTTPFactory.createPollingSocket(t)}},lt),ft={isSupported:function(){return ue.isXHRSupported()}},gt={ws:ut,xhr_streaming:new ht(N({},dt,ft)),xhr_polling:new ht(N({},pt,ft))},vt=new ht({file:"sockjs",urls:rt,handlesActivityChecks:!0,supportsPing:!1,isSupported:function(){return!0},isInitialized:function(){return void 0!==window.SockJS},getSocket:function(t,e){return new window.SockJS(t,null,{js_path:a.getPath("sockjs",{useTLS:e.useTLS}),ignore_null_origin:e.ignoreNullOrigin})},beforeOpen:function(t,e){t.send(JSON.stringify({path:e}))}}),mt={isSupported:function(t){return ue.isXDRSupported(t.useTLS)}},bt=new ht(N({},dt,mt)),yt=new ht(N({},pt,mt));gt.xdr_streaming=bt,gt.xdr_polling=yt,gt.sockjs=vt;var wt=gt;var St=new class extends at{constructor(){super();var t=this;void 0!==window.addEventListener&&(window.addEventListener("online",(function(){t.emit("online")}),!1),window.addEventListener("offline",(function(){t.emit("offline")}),!1))}isOnline(){return void 0===window.navigator.onLine||window.navigator.onLine}};class _t{constructor(t,e,n){this.manager=t,this.transport=e,this.minPingDelay=n.minPingDelay,this.maxPingDelay=n.maxPingDelay,this.pingDelay=void 0}createConnection(t,e,n,i){i=N({},i,{activityTimeout:this.pingDelay});var r=this.transport.createConnection(t,e,n,i),s=null,o=function(){r.unbind("open",o),r.bind("closed",a),s=j.now()},a=t=>{if(r.unbind("closed",a),1002===t.code||1003===t.code)this.manager.reportDeath();else if(!t.wasClean&&s){var e=j.now()-s;e<2*this.maxPingDelay&&(this.manager.reportDeath(),this.pingDelay=Math.max(e/2,this.minPingDelay))}};return r.bind("open",o),r}isSupported(t){return this.manager.isAlive()&&this.transport.isSupported(t)}}const kt={decodeMessage:function(t){try{var e=JSON.parse(t.data),n=e.data;if("string"==typeof n)try{n=JSON.parse(e.data)}catch(t){}var i={event:e.event,channel:e.channel,data:n};return e.user_id&&(i.user_id=e.user_id),i}catch(e){throw{type:"MessageParseError",error:e,data:t.data}}},encodeMessage:function(t){return JSON.stringify(t)},processHandshake:function(t){var e=kt.decodeMessage(t);if("pusher:connection_established"===e.event){if(!e.data.activity_timeout)throw"No activity timeout specified in handshake";return{action:"connected",id:e.data.socket_id,activityTimeout:1e3*e.data.activity_timeout}}if("pusher:error"===e.event)return{action:this.getCloseAction(e.data),error:this.getCloseError(e.data)};throw"Invalid handshake"},getCloseAction:function(t){return t.code<4e3?t.code>=1002&&t.code<=1004?"backoff":null:4e3===t.code?"tls_only":t.code<4100?"refused":t.code<4200?"backoff":t.code<4300?"retry":"refused"},getCloseError:function(t){return 1e3!==t.code&&1001!==t.code?{type:"PusherError",data:{code:t.code,message:t.reason||t.message}}:null}};var Ct=kt;class Tt extends at{constructor(t,e){super(),this.id=t,this.transport=e,this.activityTimeout=e.activityTimeout,this.bindListeners()}handlesActivityChecks(){return this.transport.handlesActivityChecks()}send(t){return this.transport.send(t)}send_event(t,e,n){var i={event:t,data:e};return n&&(i.channel=n),V.debug("Event sent",i),this.send(Ct.encodeMessage(i))}ping(){this.transport.supportsPing()?this.transport.ping():this.send_event("pusher:ping",{})}close(){this.transport.close()}bindListeners(){var t={message:t=>{var e;try{e=Ct.decodeMessage(t)}catch(e){this.emit("error",{type:"MessageParseError",error:e,data:t.data})}if(void 0!==e){switch(V.debug("Event recd",e),e.event){case"pusher:error":this.emit("error",{type:"PusherError",data:e.data});break;case"pusher:ping":this.emit("ping");break;case"pusher:pong":this.emit("pong")}this.emit("message",e)}},activity:()=>{this.emit("activity")},error:t=>{this.emit("error",t)},closed:t=>{e(),t&&t.code&&this.handleCloseEvent(t),this.transport=null,this.emit("closed")}},e=()=>{M(t,(t,e)=>{this.transport.unbind(e,t)})};M(t,(t,e)=>{this.transport.bind(e,t)})}handleCloseEvent(t){var e=Ct.getCloseAction(t),n=Ct.getCloseError(t);n&&this.emit("error",n),e&&this.emit(e,{action:e,error:n})}}class Pt{constructor(t,e){this.transport=t,this.callback=e,this.bindListeners()}close(){this.unbindListeners(),this.transport.close()}bindListeners(){this.onMessage=t=>{var e;this.unbindListeners();try{e=Ct.processHandshake(t)}catch(t){return this.finish("error",{error:t}),void this.transport.close()}"connected"===e.action?this.finish("connected",{connection:new Tt(e.id,this.transport),activityTimeout:e.activityTimeout}):(this.finish(e.action,{error:e.error}),this.transport.close())},this.onClosed=t=>{this.unbindListeners();var e=Ct.getCloseAction(t)||"backoff",n=Ct.getCloseError(t);this.finish(e,{error:n})},this.transport.bind("message",this.onMessage),this.transport.bind("closed",this.onClosed)}unbindListeners(){this.transport.unbind("message",this.onMessage),this.transport.unbind("closed",this.onClosed)}finish(t,e){this.callback(N({transport:this.transport,action:t},e))}}class Et{constructor(t,e){this.timeline=t,this.options=e||{}}send(t,e){this.timeline.isEmpty()||this.timeline.send(ue.TimelineTransport.getAgent(this,t),e)}}class Ot extends at{constructor(t,e){super((function(e,n){V.debug("No callbacks on "+t+" for "+e)})),this.name=t,this.pusher=e,this.subscribed=!1,this.subscriptionPending=!1,this.subscriptionCancelled=!1}authorize(t,e){return e(null,{auth:""})}trigger(t,e){if(0!==t.indexOf("client-"))throw new l("Event '"+t+"' does not start with 'client-'");if(!this.subscribed){var n=u("triggeringClientEvents");V.warn("Client event triggered before channel 'subscription_succeeded' event . "+n)}return this.pusher.send_event(t,e,this.name)}disconnect(){this.subscribed=!1,this.subscriptionPending=!1}handleEvent(t){var e=t.event,n=t.data;if("pusher_internal:subscription_succeeded"===e)this.handleSubscriptionSucceededEvent(t);else if("pusher_internal:subscription_count"===e)this.handleSubscriptionCountEvent(t);else if(0!==e.indexOf("pusher_internal:")){this.emit(e,n,{})}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):this.emit("pusher:subscription_succeeded",t.data)}handleSubscriptionCountEvent(t){t.data.subscription_count&&(this.subscriptionCount=t.data.subscription_count),this.emit("pusher:subscription_count",t.data)}subscribe(){this.subscribed||(this.subscriptionPending=!0,this.subscriptionCancelled=!1,this.authorize(this.pusher.connection.socket_id,(t,e)=>{t?(this.subscriptionPending=!1,V.error(t.toString()),this.emit("pusher:subscription_error",Object.assign({},{type:"AuthError",error:t.message},t instanceof y?{status:t.status}:{}))):this.pusher.send_event("pusher:subscribe",{auth:e.auth,channel_data:e.channel_data,channel:this.name})}))}unsubscribe(){this.subscribed=!1,this.pusher.send_event("pusher:unsubscribe",{channel:this.name})}cancelSubscription(){this.subscriptionCancelled=!0}reinstateSubscription(){this.subscriptionCancelled=!1}}class xt extends Ot{authorize(t,e){return this.pusher.config.channelAuthorizer({channelName:this.name,socketId:t},e)}}class Lt{constructor(){this.reset()}get(t){return Object.prototype.hasOwnProperty.call(this.members,t)?{id:t,info:this.members[t]}:null}each(t){M(this.members,(e,n)=>{t(this.get(n))})}setMyID(t){this.myID=t}onSubscription(t){this.members=t.presence.hash,this.count=t.presence.count,this.me=this.get(this.myID)}addMember(t){return null===this.get(t.user_id)&&this.count++,this.members[t.user_id]=t.user_info,this.get(t.user_id)}removeMember(t){var e=this.get(t.user_id);return e&&(delete this.members[t.user_id],this.count--),e}reset(){this.members={},this.count=0,this.myID=null,this.me=null}}var At=function(t,e,n,i){return new(n||(n=Promise))((function(r,s){function o(t){try{c(i.next(t))}catch(t){s(t)}}function a(t){try{c(i.throw(t))}catch(t){s(t)}}function c(t){var e;t.done?r(t.value):(e=t.value,e instanceof n?e:new n((function(t){t(e)}))).then(o,a)}c((i=i.apply(t,e||[])).next())}))};class Rt extends xt{constructor(t,e){super(t,e),this.members=new Lt}authorize(t,e){super.authorize(t,(t,n)=>At(this,void 0,void 0,(function*(){if(!t)if(null!=(n=n).channel_data){var i=JSON.parse(n.channel_data);this.members.setMyID(i.user_id)}else{if(yield this.pusher.user.signinDonePromise,null==this.pusher.user.user_data){let t=u("authorizationEndpoint");return V.error(`Invalid auth response for channel '${this.name}', expected 'channel_data' field. ${t}, or the user should be signed in.`),void e("Invalid auth response")}this.members.setMyID(this.pusher.user.user_data.id)}e(t,n)})))}handleEvent(t){var e=t.event;if(0===e.indexOf("pusher_internal:"))this.handleInternalEvent(t);else{var n=t.data,i={};t.user_id&&(i.user_id=t.user_id),this.emit(e,n,i)}}handleInternalEvent(t){var e=t.event,n=t.data;switch(e){case"pusher_internal:subscription_succeeded":this.handleSubscriptionSucceededEvent(t);break;case"pusher_internal:subscription_count":this.handleSubscriptionCountEvent(t);break;case"pusher_internal:member_added":var i=this.members.addMember(n);this.emit("pusher:member_added",i);break;case"pusher_internal:member_removed":var r=this.members.removeMember(n);r&&this.emit("pusher:member_removed",r)}}handleSubscriptionSucceededEvent(t){this.subscriptionPending=!1,this.subscribed=!0,this.subscriptionCancelled?this.pusher.unsubscribe(this.name):(this.members.onSubscription(t.data),this.emit("pusher:subscription_succeeded",this.members))}disconnect(){this.members.reset(),super.disconnect()}}var It=n(1),Dt=n(0);class jt extends xt{constructor(t,e,n){super(t,e),this.key=null,this.nacl=n}authorize(t,e){super.authorize(t,(t,n)=>{if(t)return void e(t,n);let i=n.shared_secret;i?(this.key=Object(Dt.decode)(i),delete n.shared_secret,e(null,n)):e(new Error("No shared_secret key in auth payload for encrypted channel: "+this.name),null)})}trigger(t,e){throw new v("Client events are not currently supported for encrypted channels")}handleEvent(t){var e=t.event,n=t.data;0!==e.indexOf("pusher_internal:")&&0!==e.indexOf("pusher:")?this.handleEncryptedEvent(e,n):super.handleEvent(t)}handleEncryptedEvent(t,e){if(!this.key)return void V.debug("Received encrypted event before key has been retrieved from the authEndpoint");if(!e.ciphertext||!e.nonce)return void V.error("Unexpected format for encrypted event, expected object with `ciphertext` and `nonce` fields, got: "+e);let n=Object(Dt.decode)(e.ciphertext);if(n.length{e?V.error(`Failed to make a request to the authEndpoint: ${s}. Unable to fetch new key, so dropping encrypted event`):(r=this.nacl.secretbox.open(n,i,this.key),null!==r?this.emit(t,this.getDataToEmit(r)):V.error("Failed to decrypt event with new key. Dropping encrypted event"))});this.emit(t,this.getDataToEmit(r))}getDataToEmit(t){let e=Object(It.decode)(t);try{return JSON.parse(e)}catch(t){return e}}}class Nt extends at{constructor(t,e){super(),this.state="initialized",this.connection=null,this.key=t,this.options=e,this.timeline=this.options.timeline,this.usingTLS=this.options.useTLS,this.errorCallbacks=this.buildErrorCallbacks(),this.connectionCallbacks=this.buildConnectionCallbacks(this.errorCallbacks),this.handshakeCallbacks=this.buildHandshakeCallbacks(this.errorCallbacks);var n=ue.getNetwork();n.bind("online",()=>{this.timeline.info({netinfo:"online"}),"connecting"!==this.state&&"unavailable"!==this.state||this.retryIn(0)}),n.bind("offline",()=>{this.timeline.info({netinfo:"offline"}),this.connection&&this.sendActivityCheck()}),this.updateStrategy()}connect(){this.connection||this.runner||(this.strategy.isSupported()?(this.updateState("connecting"),this.startConnecting(),this.setUnavailableTimer()):this.updateState("failed"))}send(t){return!!this.connection&&this.connection.send(t)}send_event(t,e,n){return!!this.connection&&this.connection.send_event(t,e,n)}disconnect(){this.disconnectInternally(),this.updateState("disconnected")}isUsingTLS(){return this.usingTLS}startConnecting(){var t=(e,n)=>{e?this.runner=this.strategy.connect(0,t):"error"===n.action?(this.emit("error",{type:"HandshakeError",error:n.error}),this.timeline.error({handshakeError:n.error})):(this.abortConnecting(),this.handshakeCallbacks[n.action](n))};this.runner=this.strategy.connect(0,t)}abortConnecting(){this.runner&&(this.runner.abort(),this.runner=null)}disconnectInternally(){(this.abortConnecting(),this.clearRetryTimer(),this.clearUnavailableTimer(),this.connection)&&this.abandonConnection().close()}updateStrategy(){this.strategy=this.options.getStrategy({key:this.key,timeline:this.timeline,useTLS:this.usingTLS})}retryIn(t){this.timeline.info({action:"retry",delay:t}),t>0&&this.emit("connecting_in",Math.round(t/1e3)),this.retryTimer=new I(t||0,()=>{this.disconnectInternally(),this.connect()})}clearRetryTimer(){this.retryTimer&&(this.retryTimer.ensureAborted(),this.retryTimer=null)}setUnavailableTimer(){this.unavailableTimer=new I(this.options.unavailableTimeout,()=>{this.updateState("unavailable")})}clearUnavailableTimer(){this.unavailableTimer&&this.unavailableTimer.ensureAborted()}sendActivityCheck(){this.stopActivityCheck(),this.connection.ping(),this.activityTimer=new I(this.options.pongTimeout,()=>{this.timeline.error({pong_timed_out:this.options.pongTimeout}),this.retryIn(0)})}resetActivityCheck(){this.stopActivityCheck(),this.connection&&!this.connection.handlesActivityChecks()&&(this.activityTimer=new I(this.activityTimeout,()=>{this.sendActivityCheck()}))}stopActivityCheck(){this.activityTimer&&this.activityTimer.ensureAborted()}buildConnectionCallbacks(t){return N({},t,{message:t=>{this.resetActivityCheck(),this.emit("message",t)},ping:()=>{this.send_event("pusher:pong",{})},activity:()=>{this.resetActivityCheck()},error:t=>{this.emit("error",t)},closed:()=>{this.abandonConnection(),this.shouldRetry()&&this.retryIn(1e3)}})}buildHandshakeCallbacks(t){return N({},t,{connected:t=>{this.activityTimeout=Math.min(this.options.activityTimeout,t.activityTimeout,t.connection.activityTimeout||1/0),this.clearUnavailableTimer(),this.setConnection(t.connection),this.socket_id=this.connection.id,this.updateState("connected",{socket_id:this.socket_id})}})}buildErrorCallbacks(){let t=t=>e=>{e.error&&this.emit("error",{type:"WebSocketError",error:e.error}),t(e)};return{tls_only:t(()=>{this.usingTLS=!0,this.updateStrategy(),this.retryIn(0)}),refused:t(()=>{this.disconnect()}),backoff:t(()=>{this.retryIn(1e3)}),retry:t(()=>{this.retryIn(0)})}}setConnection(t){for(var e in this.connection=t,this.connectionCallbacks)this.connection.bind(e,this.connectionCallbacks[e]);this.resetActivityCheck()}abandonConnection(){if(this.connection){for(var t in this.stopActivityCheck(),this.connectionCallbacks)this.connection.unbind(t,this.connectionCallbacks[t]);var e=this.connection;return this.connection=null,e}}updateState(t,e){var n=this.state;if(this.state=t,n!==t){var i=t;"connected"===i&&(i+=" with new socket ID "+e.socket_id),V.debug("State changed",n+" -> "+i),this.timeline.info({state:t,params:e}),this.emit("state_change",{previous:n,current:t}),this.emit(t,e)}}shouldRetry(){return"connecting"===this.state||"connected"===this.state}}class Ht{constructor(){this.channels={}}add(t,e){return this.channels[t]||(this.channels[t]=function(t,e){if(0===t.indexOf("private-encrypted-")){if(e.config.nacl)return Ut.createEncryptedChannel(t,e,e.config.nacl);let n="Tried to subscribe to a private-encrypted- channel but no nacl implementation available",i=u("encryptedChannelSupport");throw new v(`${n}. ${i}`)}if(0===t.indexOf("private-"))return Ut.createPrivateChannel(t,e);if(0===t.indexOf("presence-"))return Ut.createPresenceChannel(t,e);if(0===t.indexOf("#"))throw new d('Cannot create a channel with name "'+t+'".');return Ut.createChannel(t,e)}(t,e)),this.channels[t]}all(){return function(t){var e=[];return M(t,(function(t){e.push(t)})),e}(this.channels)}find(t){return this.channels[t]}remove(t){var e=this.channels[t];return delete this.channels[t],e}disconnect(){M(this.channels,(function(t){t.disconnect()}))}}var Ut={createChannels:()=>new Ht,createConnectionManager:(t,e)=>new Nt(t,e),createChannel:(t,e)=>new Ot(t,e),createPrivateChannel:(t,e)=>new xt(t,e),createPresenceChannel:(t,e)=>new Rt(t,e),createEncryptedChannel:(t,e,n)=>new jt(t,e,n),createTimelineSender:(t,e)=>new Et(t,e),createHandshake:(t,e)=>new Pt(t,e),createAssistantToTheTransportManager:(t,e,n)=>new _t(t,e,n)};class Mt{constructor(t){this.options=t||{},this.livesLeft=this.options.lives||1/0}getAssistant(t){return Ut.createAssistantToTheTransportManager(this,t,{minPingDelay:this.options.minPingDelay,maxPingDelay:this.options.maxPingDelay})}isAlive(){return this.livesLeft>0}reportDeath(){this.livesLeft-=1}}class zt{constructor(t,e){this.strategies=t,this.loop=Boolean(e.loop),this.failFast=Boolean(e.failFast),this.timeout=e.timeout,this.timeoutLimit=e.timeoutLimit}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){var n=this.strategies,i=0,r=this.timeout,s=null,o=(a,c)=>{c?e(null,c):(i+=1,this.loop&&(i%=n.length),i0&&(r=new I(n.timeout,(function(){s.abort(),i(!0)}))),s=t.connect(e,(function(t,e){t&&r&&r.isRunning()&&!n.failFast||(r&&r.ensureAborted(),i(t,e))})),{abort:function(){r&&r.ensureAborted(),s.abort()},forceMinPriority:function(t){s.forceMinPriority(t)}}}}class qt{constructor(t){this.strategies=t}isSupported(){return J(this.strategies,j.method("isSupported"))}connect(t,e){return function(t,e,n){var i=B(t,(function(t,i,r,s){return t.connect(e,n(i,s))}));return{abort:function(){q(i,Bt)},forceMinPriority:function(t){q(i,(function(e){e.forceMinPriority(t)}))}}}(this.strategies,t,(function(t,n){return function(i,r){n[t].error=i,i?function(t){return function(t,e){for(var n=0;n=j.now()){var o=this.transports[i.transport];o&&(["ws","wss"].includes(i.transport)||r>3?(this.timeline.info({cached:!0,transport:i.transport,latency:i.latency}),s.push(new zt([o],{timeout:2*i.latency+1e3,failFast:!0}))):r++)}var a=j.now(),c=s.pop().connect(t,(function i(o,h){o?(Jt(n),s.length>0?(a=j.now(),c=s.pop().connect(t,i)):e(o)):(!function(t,e,n,i){var r=ue.getLocalStorage();if(r)try{r[Xt(t)]=G({timestamp:j.now(),transport:e,latency:n,cacheSkipCount:i})}catch(t){}}(n,h.transport.name,j.now()-a,r),e(null,h))}));return{abort:function(){c.abort()},forceMinPriority:function(e){t=e,c&&c.forceMinPriority(e)}}}}function Xt(t){return"pusherTransport"+(t?"TLS":"NonTLS")}function Jt(t){var e=ue.getLocalStorage();if(e)try{delete e[Xt(t)]}catch(t){}}class $t{constructor(t,{delay:e}){this.strategy=t,this.options={delay:e}}isSupported(){return this.strategy.isSupported()}connect(t,e){var n,i=this.strategy,r=new I(this.options.delay,(function(){n=i.connect(t,e)}));return{abort:function(){r.ensureAborted(),n&&n.abort()},forceMinPriority:function(e){t=e,n&&n.forceMinPriority(e)}}}}class Wt{constructor(t,e,n){this.test=t,this.trueBranch=e,this.falseBranch=n}isSupported(){return(this.test()?this.trueBranch:this.falseBranch).isSupported()}connect(t,e){return(this.test()?this.trueBranch:this.falseBranch).connect(t,e)}}class Gt{constructor(t){this.strategy=t}isSupported(){return this.strategy.isSupported()}connect(t,e){var n=this.strategy.connect(t,(function(t,i){i&&n.abort(),e(t,i)}));return n}}function Vt(t){return function(){return t.isSupported()}}var Yt=function(t,e,n){var i={};function r(e,r,s,o,a){var c=n(t,e,r,s,o,a);return i[e]=c,c}var s,o=Object.assign({},e,{hostNonTLS:t.wsHost+":"+t.wsPort,hostTLS:t.wsHost+":"+t.wssPort,httpPath:t.wsPath}),a=Object.assign({},o,{useTLS:!0}),c=Object.assign({},e,{hostNonTLS:t.httpHost+":"+t.httpPort,hostTLS:t.httpHost+":"+t.httpsPort,httpPath:t.httpPath}),h={loop:!0,timeout:15e3,timeoutLimit:6e4},u=new Mt({minPingDelay:1e4,maxPingDelay:t.activityTimeout}),l=new Mt({lives:2,minPingDelay:1e4,maxPingDelay:t.activityTimeout}),d=r("ws","ws",3,o,u),p=r("wss","ws",3,a,u),f=r("sockjs","sockjs",1,c),g=r("xhr_streaming","xhr_streaming",1,c,l),v=r("xdr_streaming","xdr_streaming",1,c,l),m=r("xhr_polling","xhr_polling",1,c),b=r("xdr_polling","xdr_polling",1,c),y=new zt([d],h),w=new zt([p],h),S=new zt([f],h),_=new zt([new Wt(Vt(g),g,v)],h),k=new zt([new Wt(Vt(m),m,b)],h),C=new zt([new Wt(Vt(_),new qt([_,new $t(k,{delay:4e3})]),k)],h),T=new Wt(Vt(C),C,S);return s=e.useTLS?new qt([y,new $t(T,{delay:2e3})]):new qt([y,new $t(w,{delay:2e3}),new $t(T,{delay:5e3})]),new Ft(new Gt(new Wt(Vt(d),s,T)),i,{ttl:18e5,timeline:e.timeline,useTLS:e.useTLS})},Qt={getRequest:function(t){var e=new window.XDomainRequest;return e.ontimeout=function(){t.emit("error",new p),t.close()},e.onerror=function(e){t.emit("error",e),t.close()},e.onprogress=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText)},e.onload=function(){e.responseText&&e.responseText.length>0&&t.onChunk(200,e.responseText),t.emit("finished",200),t.close()},e},abortRequest:function(t){t.ontimeout=t.onerror=t.onprogress=t.onload=null,t.abort()}};class Kt extends at{constructor(t,e,n){super(),this.hooks=t,this.method=e,this.url=n}start(t){this.position=0,this.xhr=this.hooks.getRequest(this),this.unloader=()=>{this.close()},ue.addUnloadListener(this.unloader),this.xhr.open(this.method,this.url,!0),this.xhr.setRequestHeader&&this.xhr.setRequestHeader("Content-Type","application/json"),this.xhr.send(t)}close(){this.unloader&&(ue.removeUnloadListener(this.unloader),this.unloader=null),this.xhr&&(this.hooks.abortRequest(this.xhr),this.xhr=null)}onChunk(t,e){for(;;){var n=this.advanceBuffer(e);if(!n)break;this.emit("chunk",{status:t,data:n})}this.isBufferTooLong(e)&&this.emit("buffer_too_long")}advanceBuffer(t){var e=t.slice(this.position),n=e.indexOf("\n");return-1!==n?(this.position+=n+1,e.slice(0,n)):null}isBufferTooLong(t){return this.position===t.length&&t.length>262144}}var Zt;!function(t){t[t.CONNECTING=0]="CONNECTING",t[t.OPEN=1]="OPEN",t[t.CLOSED=3]="CLOSED"}(Zt||(Zt={}));var te=Zt,ee=1;function ne(t){var e=-1===t.indexOf("?")?"?":"&";return t+e+"t="+ +new Date+"&n="+ee++}function ie(t){return ue.randomInt(t)}var re,se=class{constructor(t,e){this.hooks=t,this.session=ie(1e3)+"/"+function(t){for(var e=[],n=0;n{this.onChunk(t)}),this.stream.bind("finished",t=>{this.hooks.onFinished(this,t)}),this.stream.bind("buffer_too_long",()=>{this.reconnect()});try{this.stream.start()}catch(t){j.defer(()=>{this.onError(t),this.onClose(1006,"Could not start streaming",!1)})}}closeStream(){this.stream&&(this.stream.unbind_all(),this.stream.close(),this.stream=null)}},oe={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr_streaming"+t.queryString},onHeartbeat:function(t){t.sendRaw("[]")},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ae={getReceiveURL:function(t,e){return t.base+"/"+e+"/xhr"+t.queryString},onHeartbeat:function(){},sendHeartbeat:function(t){t.sendRaw("[]")},onFinished:function(t,e){200===e?t.reconnect():t.onClose(1006,"Connection interrupted ("+e+")",!1)}},ce={getRequest:function(t){var e=new(ue.getXHRAPI());return e.onreadystatechange=e.onprogress=function(){switch(e.readyState){case 3:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText);break;case 4:e.responseText&&e.responseText.length>0&&t.onChunk(e.status,e.responseText),t.emit("finished",e.status),t.close()}},e},abortRequest:function(t){t.onreadystatechange=null,t.abort()}},he={createStreamingSocket(t){return this.createSocket(oe,t)},createPollingSocket(t){return this.createSocket(ae,t)},createSocket:(t,e)=>new se(t,e),createXHR(t,e){return this.createRequest(ce,t,e)},createRequest:(t,e,n)=>new Kt(t,e,n),createXDR:function(t,e){return this.createRequest(Qt,t,e)}},ue={nextAuthCallbackID:1,auth_callbacks:{},ScriptReceivers:r,DependenciesReceivers:o,getDefaultStrategy:Yt,Transports:wt,transportConnectionInitializer:function(){var t=this;t.timeline.info(t.buildTimelineMessage({transport:t.name+(t.options.useTLS?"s":"")})),t.hooks.isInitialized()?t.changeState("initialized"):t.hooks.file?(t.changeState("initializing"),a.load(t.hooks.file,{useTLS:t.options.useTLS},(function(e,n){t.hooks.isInitialized()?(t.changeState("initialized"),n(!0)):(e&&t.onError(e),t.onClose(),n(!1))}))):t.onClose()},HTTPFactory:he,TimelineTransport:Z,getXHRAPI:()=>window.XMLHttpRequest,getWebSocketAPI:()=>window.WebSocket||window.MozWebSocket,setup(t){window.Pusher=t;var e=()=>{this.onDocumentBody(t.ready)};window.JSON?e():a.load("json2",{},e)},getDocument:()=>document,getProtocol(){return this.getDocument().location.protocol},getAuthorizers:()=>({ajax:w,jsonp:Y}),onDocumentBody(t){document.body?t():setTimeout(()=>{this.onDocumentBody(t)},0)},createJSONPRequest:(t,e)=>new K(t,e),createScriptRequest:t=>new Q(t),getLocalStorage(){try{return window.localStorage}catch(t){return}},createXHR(){return this.getXHRAPI()?this.createXMLHttpRequest():this.createMicrosoftXHR()},createXMLHttpRequest(){return new(this.getXHRAPI())},createMicrosoftXHR:()=>new ActiveXObject("Microsoft.XMLHTTP"),getNetwork:()=>St,createWebSocket(t){return new(this.getWebSocketAPI())(t)},createSocketRequest(t,e){if(this.isXHRSupported())return this.HTTPFactory.createXHR(t,e);if(this.isXDRSupported(0===e.indexOf("https:")))return this.HTTPFactory.createXDR(t,e);throw"Cross-origin HTTP requests are not supported"},isXHRSupported(){var t=this.getXHRAPI();return Boolean(t)&&void 0!==(new t).withCredentials},isXDRSupported(t){var e=t?"https:":"http:",n=this.getProtocol();return Boolean(window.XDomainRequest)&&n===e},addUnloadListener(t){void 0!==window.addEventListener?window.addEventListener("unload",t,!1):void 0!==window.attachEvent&&window.attachEvent("onunload",t)},removeUnloadListener(t){void 0!==window.addEventListener?window.removeEventListener("unload",t,!1):void 0!==window.detachEvent&&window.detachEvent("onunload",t)},randomInt:t=>Math.floor((window.crypto||window.msCrypto).getRandomValues(new Uint32Array(1))[0]/Math.pow(2,32)*t)};!function(t){t[t.ERROR=3]="ERROR",t[t.INFO=6]="INFO",t[t.DEBUG=7]="DEBUG"}(re||(re={}));var le=re;class de{constructor(t,e,n){this.key=t,this.session=e,this.events=[],this.options=n||{},this.sent=0,this.uniqueID=0}log(t,e){t<=this.options.level&&(this.events.push(N({},e,{timestamp:j.now()})),this.options.limit&&this.events.length>this.options.limit&&this.events.shift())}error(t){this.log(le.ERROR,t)}info(t){this.log(le.INFO,t)}debug(t){this.log(le.DEBUG,t)}isEmpty(){return 0===this.events.length}send(t,e){var n=N({session:this.session,bundle:this.sent+1,key:this.key,lib:"js",version:this.options.version,cluster:this.options.cluster,features:this.options.features,timeline:this.events},this.options.params);return this.events=[],t(n,(t,n)=>{t||this.sent++,e&&e(t,n)}),!0}generateUniqueID(){return this.uniqueID++,this.uniqueID}}class pe{constructor(t,e,n,i){this.name=t,this.priority=e,this.transport=n,this.options=i||{}}isSupported(){return this.transport.isSupported({useTLS:this.options.useTLS})}connect(t,e){if(!this.isSupported())return fe(new b,e);if(this.priority{n||(h(),r?r.close():i.close())},forceMinPriority:t=>{n||this.priority{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.UserAuthentication,n)}};var ye=t=>{if(void 0===ue.getAuthorizers()[t.transport])throw`'${t.transport}' is not a recognized auth transport`;return(e,n)=>{const i=((t,e)=>{var n="socket_id="+encodeURIComponent(t.socketId);for(var i in n+="&channel_name="+encodeURIComponent(t.channelName),e.params)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(e.params[i]);if(null!=e.paramsProvider){let t=e.paramsProvider();for(var i in t)n+="&"+encodeURIComponent(i)+"="+encodeURIComponent(t[i])}return n})(e,t);ue.getAuthorizers()[t.transport](ue,i,t,h.ChannelAuthorization,n)}};function we(t){return t.httpHost?t.httpHost:t.cluster?`sockjs-${t.cluster}.pusher.com`:s.httpHost}function Se(t){return t.wsHost?t.wsHost:`ws-${t.cluster}.pusher.com`}function _e(t){return"https:"===ue.getProtocol()||!1!==t.forceTLS}function ke(t){return"enableStats"in t?t.enableStats:"disableStats"in t&&!t.disableStats}function Ce(t){const e=Object.assign(Object.assign({},s.userAuthentication),t.userAuthentication);return"customHandler"in e&&null!=e.customHandler?e.customHandler:be(e)}function Te(t,e){const n=function(t,e){let n;return"channelAuthorization"in t?n=Object.assign(Object.assign({},s.channelAuthorization),t.channelAuthorization):(n={transport:t.authTransport||s.authTransport,endpoint:t.authEndpoint||s.authEndpoint},"auth"in t&&("params"in t.auth&&(n.params=t.auth.params),"headers"in t.auth&&(n.headers=t.auth.headers)),"authorizer"in t&&(n.customHandler=((t,e,n)=>{const i={authTransport:e.transport,authEndpoint:e.endpoint,auth:{params:e.params,headers:e.headers}};return(e,r)=>{const s=t.channel(e.channelName);n(s,i).authorize(e.socketId,r)}})(e,n,t.authorizer))),n}(t,e);return"customHandler"in n&&null!=n.customHandler?n.customHandler:ye(n)}class Pe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on watchlist events for "+t)})),this.pusher=t,this.bindWatchlistInternalEvent()}handleEvent(t){t.data.events.forEach(t=>{this.emit(t.name,t)})}bindWatchlistInternalEvent(){this.pusher.connection.bind("message",t=>{"pusher_internal:watchlist_events"===t.event&&this.handleEvent(t)})}}var Ee=function(){let t,e;return{promise:new Promise((n,i)=>{t=n,e=i}),resolve:t,reject:e}};class Oe extends at{constructor(t){super((function(t,e){V.debug("No callbacks on user for "+t)})),this.signin_requested=!1,this.user_data=null,this.serverToUserChannel=null,this.signinDonePromise=null,this._signinDoneResolve=null,this._onAuthorize=(t,e)=>{if(t)return V.warn("Error during signin: "+t),void this._cleanup();this.pusher.send_event("pusher:signin",{auth:e.auth,user_data:e.user_data})},this.pusher=t,this.pusher.connection.bind("state_change",({previous:t,current:e})=>{"connected"!==t&&"connected"===e&&this._signin(),"connected"===t&&"connected"!==e&&(this._cleanup(),this._newSigninPromiseIfNeeded())}),this.watchlist=new Pe(t),this.pusher.connection.bind("message",t=>{"pusher:signin_success"===t.event&&this._onSigninSuccess(t.data),this.serverToUserChannel&&this.serverToUserChannel.name===t.channel&&this.serverToUserChannel.handleEvent(t)})}signin(){this.signin_requested||(this.signin_requested=!0,this._signin())}_signin(){this.signin_requested&&(this._newSigninPromiseIfNeeded(),"connected"===this.pusher.connection.state&&this.pusher.config.userAuthenticator({socketId:this.pusher.connection.socket_id},this._onAuthorize))}_onSigninSuccess(t){try{this.user_data=JSON.parse(t.user_data)}catch(e){return V.error("Failed parsing user data after signin: "+t.user_data),void this._cleanup()}if("string"!=typeof this.user_data.id||""===this.user_data.id)return V.error("user_data doesn't contain an id. user_data: "+this.user_data),void this._cleanup();this._signinDoneResolve(),this._subscribeChannels()}_subscribeChannels(){this.serverToUserChannel=new Ot("#server-to-user-"+this.user_data.id,this.pusher),this.serverToUserChannel.bind_global((t,e)=>{0!==t.indexOf("pusher_internal:")&&0!==t.indexOf("pusher:")&&this.emit(t,e)}),(t=>{t.subscriptionPending&&t.subscriptionCancelled?t.reinstateSubscription():t.subscriptionPending||"connected"!==this.pusher.connection.state||t.subscribe()})(this.serverToUserChannel)}_cleanup(){this.user_data=null,this.serverToUserChannel&&(this.serverToUserChannel.unbind_all(),this.serverToUserChannel.disconnect(),this.serverToUserChannel=null),this.signin_requested&&this._signinDoneResolve()}_newSigninPromiseIfNeeded(){if(!this.signin_requested)return;if(this.signinDonePromise&&!this.signinDonePromise.done)return;const{promise:t,resolve:e,reject:n}=Ee();t.done=!1;const i=()=>{t.done=!0};t.then(i).catch(i),this.signinDonePromise=t,this._signinDoneResolve=e}}class xe{static ready(){xe.isReady=!0;for(var t=0,e=xe.instances.length;tue.getDefaultStrategy(this.config,t,ve),timeline:this.timeline,activityTimeout:this.config.activityTimeout,pongTimeout:this.config.pongTimeout,unavailableTimeout:this.config.unavailableTimeout,useTLS:Boolean(this.config.useTLS)}),this.connection.bind("connected",()=>{this.subscribeAll(),this.timelineSender&&this.timelineSender.send(this.connection.isUsingTLS())}),this.connection.bind("message",t=>{var e=0===t.event.indexOf("pusher_internal:");if(t.channel){var n=this.channel(t.channel);n&&n.handleEvent(t)}e||this.global_emitter.emit(t.event,t.data)}),this.connection.bind("connecting",()=>{this.channels.disconnect()}),this.connection.bind("disconnected",()=>{this.channels.disconnect()}),this.connection.bind("error",t=>{V.warn(t)}),xe.instances.push(this),this.timeline.info({instances:xe.instances.length}),this.user=new Oe(this),xe.isReady&&this.connect()}channel(t){return this.channels.find(t)}allChannels(){return this.channels.all()}connect(){if(this.connection.connect(),this.timelineSender&&!this.timelineSenderTimer){var t=this.connection.isUsingTLS(),e=this.timelineSender;this.timelineSenderTimer=new D(6e4,(function(){e.send(t)}))}}disconnect(){this.connection.disconnect(),this.timelineSenderTimer&&(this.timelineSenderTimer.ensureAborted(),this.timelineSenderTimer=null)}bind(t,e,n){return this.global_emitter.bind(t,e,n),this}unbind(t,e,n){return this.global_emitter.unbind(t,e,n),this}bind_global(t){return this.global_emitter.bind_global(t),this}unbind_global(t){return this.global_emitter.unbind_global(t),this}unbind_all(t){return this.global_emitter.unbind_all(),this}subscribeAll(){var t;for(t in this.channels.channels)this.channels.channels.hasOwnProperty(t)&&this.subscribe(t)}subscribe(t){var e=this.channels.add(t,this);return e.subscriptionPending&&e.subscriptionCancelled?e.reinstateSubscription():e.subscriptionPending||"connected"!==this.connection.state||e.subscribe(),e}unsubscribe(t){var e=this.channels.find(t);e&&e.subscriptionPending?e.cancelSubscription():(e=this.channels.remove(t))&&e.subscribed&&e.unsubscribe()}send_event(t,e,n){return this.connection.send_event(t,e,n)}shouldUseTLS(){return this.config.useTLS}signin(){this.user.signin()}}xe.instances=[],xe.isReady=!1,xe.logToConsole=!1,xe.Runtime=ue,xe.ScriptReceivers=ue.ScriptReceivers,xe.DependenciesReceivers=ue.DependenciesReceivers,xe.auth_callbacks=ue.auth_callbacks;var Le=e.default=xe;ue.setup(xe)}])}));
//# sourceMappingURL=pusher.min.js.map
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/public/svgs/cloudflare-ddns.svg b/public/svgs/cloudflare-ddns.svg
new file mode 100644
index 000000000..efe800bcc
--- /dev/null
+++ b/public/svgs/cloudflare-ddns.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/svgs/emqx-enterprise.svg b/public/svgs/emqx-enterprise.svg
new file mode 100644
index 000000000..e67e1bffe
--- /dev/null
+++ b/public/svgs/emqx-enterprise.svg
@@ -0,0 +1,7 @@
+
+
diff --git a/public/svgs/healthchecks.webp b/public/svgs/healthchecks.webp
new file mode 100644
index 000000000..003f05f3f
Binary files /dev/null and b/public/svgs/healthchecks.webp differ
diff --git a/public/svgs/hermes-agent.png b/public/svgs/hermes-agent.png
new file mode 100644
index 000000000..0d4a8e82a
Binary files /dev/null and b/public/svgs/hermes-agent.png differ
diff --git a/public/svgs/openobserve.svg b/public/svgs/openobserve.svg
new file mode 100644
index 000000000..c687d948b
--- /dev/null
+++ b/public/svgs/openobserve.svg
@@ -0,0 +1,39 @@
+
diff --git a/resources/css/app.css b/resources/css/app.css
index 936e0c713..de92bf0c9 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -53,6 +53,13 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
+
+@layer components {
+ .terminal-mobile-key {
+ @apply min-h-10 rounded-md border border-white/10 bg-white/10 px-2 py-2 text-sm font-semibold text-white shadow-inner active:bg-white/25;
+ }
+}
+
@layer base {
*,
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index a8e807041..170e6ac16 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -181,7 +181,7 @@
@apply 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;
}
@utility menu-item-icon {
- @apply flex-shrink-0 w-6 h-6 dark:hover:text-white;
+ @apply shrink-0 size-4 dark:hover:text-white;
}
@utility menu-item-label {
@@ -201,7 +201,7 @@
}
@utility sub-menu-item-icon {
- @apply flex-shrink-0 w-4 h-4 dark:hover:text-white;
+ @apply shrink-0 size-4 dark:hover:text-white;
}
@utility heading-item-active {
@@ -343,3 +343,16 @@
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}
+
+@media (min-width: 1024px) {
+ .sidebar-collapsed .menu-item {
+ justify-content: center;
+ width: var(--button-h, 2rem);
+ height: var(--button-h, 2rem);
+ min-height: var(--button-h, 2rem);
+ padding-left: 0;
+ padding-right: 0;
+ gap: 0;
+ margin-inline: auto;
+ }
+}
diff --git a/resources/js/app.js b/resources/js/app.js
index 4dcae5f8e..96085bd96 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,5 +1,13 @@
import { initializeTerminalComponent } from './terminal.js';
+// Livewire 3.5.19+ re-applies `x-cloak` to morphed elements during wire:navigate
+// (via replaceHtmlAttributes). With `[x-cloak]{display:none}` on the app wrapper,
+// this blanks the whole page on every navigation until Alpine re-processes it.
+// Strip leftover x-cloak after each navigation; the initial-load FOUC guard stays.
+document.addEventListener('livewire:navigated', () => {
+ document.querySelectorAll('[x-cloak]').forEach((el) => el.removeAttribute('x-cloak'));
+});
+
['livewire:navigated', 'alpine:init'].forEach((event) => {
document.addEventListener(event, () => {
// tree-shaking
diff --git a/resources/js/terminal-session-timer.js b/resources/js/terminal-session-timer.js
new file mode 100644
index 000000000..60c7f7311
--- /dev/null
+++ b/resources/js/terminal-session-timer.js
@@ -0,0 +1,22 @@
+export const MAX_TERMINAL_SESSION_SECONDS = 8 * 60 * 60;
+export const TERMINAL_SESSION_WARNING_SECONDS = 30 * 60;
+export const TERMINAL_SESSION_DANGER_SECONDS = 5 * 60;
+
+export function formatTerminalSessionRemainingTime(seconds) {
+ const remainingSeconds = Math.max(0, Math.ceil(seconds));
+
+ if (remainingSeconds === 0) {
+ return 'expired';
+ }
+
+ const totalMinutes = Math.floor(remainingSeconds / 60);
+ const hours = Math.floor(totalMinutes / 60);
+ const minutes = totalMinutes % 60;
+ const secondsPart = remainingSeconds % 60;
+
+ if (hours === 0) {
+ return `${minutes}m ${String(secondsPart).padStart(2, '0')}s`;
+ }
+
+ return `${hours}h ${String(minutes).padStart(2, '0')}m ${String(secondsPart).padStart(2, '0')}s`;
+}
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index aa5f37353..9dc571e26 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -1,5 +1,11 @@
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
+import {
+ MAX_TERMINAL_SESSION_SECONDS,
+ TERMINAL_SESSION_DANGER_SECONDS,
+ TERMINAL_SESSION_WARNING_SECONDS,
+ formatTerminalSessionRemainingTime,
+} from './terminal-session-timer.js';
import { FitAddon } from '@xterm/addon-fit';
const terminalDebugEnabled = import.meta.env.DEV;
@@ -42,12 +48,20 @@ 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, unprocessable).
+ lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
// Visibility handling - prevent disconnects when tab loses focus
isDocumentVisible: true,
wasConnectedBeforeHidden: false,
+ mobileToolbarCollapsed: false,
+ terminalSessionStartedAt: null,
+ terminalSessionRemainingSeconds: null,
+ terminalSessionCountdownInterval: null,
init() {
this.setupTerminal();
@@ -75,8 +89,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
- this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
-
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@@ -133,6 +145,7 @@ export function initializeTerminalComponent() {
this.clearAllTimers();
this.connectionState = 'disconnected';
this.pendingCommand = null;
+ this.resetTerminalSessionCountdown();
if (this.socket) {
this.socket.close(1000, 'Client cleanup');
}
@@ -150,24 +163,93 @@ 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));
+ if (this.terminalSessionCountdownInterval) {
+ clearInterval(this.terminalSessionCountdownInterval);
+ }
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
this.pingTimeoutId = null;
this.resizeTimeout = null;
+ this.terminalSessionCountdownInterval = null;
+ },
+
+ resetTerminalSessionCountdown() {
+ if (this.terminalSessionCountdownInterval) {
+ clearInterval(this.terminalSessionCountdownInterval);
+ }
+
+ this.terminalSessionStartedAt = null;
+ this.terminalSessionRemainingSeconds = null;
+ this.terminalSessionCountdownInterval = null;
+ },
+
+ startTerminalSessionCountdown() {
+ this.resetTerminalSessionCountdown();
+ this.terminalSessionStartedAt = Date.now();
+ this.updateTerminalSessionCountdown();
+ this.terminalSessionCountdownInterval = setInterval(() => {
+ this.updateTerminalSessionCountdown();
+ }, 1000);
+ },
+
+ updateTerminalSessionCountdown() {
+ if (!this.terminalSessionStartedAt) {
+ this.terminalSessionRemainingSeconds = null;
+ return;
+ }
+
+ const elapsedSeconds = (Date.now() - this.terminalSessionStartedAt) / 1000;
+ this.terminalSessionRemainingSeconds = Math.max(0, MAX_TERMINAL_SESSION_SECONDS - elapsedSeconds);
+ },
+
+ terminalSessionRemainingLabel() {
+ if (this.terminalSessionRemainingSeconds === null) {
+ return '';
+ }
+
+ return `Session expires in ${formatTerminalSessionRemainingTime(this.terminalSessionRemainingSeconds)}`;
+ },
+
+ terminalSessionTimerClass() {
+ if (this.terminalSessionRemainingSeconds === null) {
+ return 'text-neutral-300 bg-black/70 border-white/10';
+ }
+
+ if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_DANGER_SECONDS) {
+ return 'text-red-200 bg-red-950/80 border-red-500/40';
+ }
+
+ if (this.terminalSessionRemainingSeconds <= TERMINAL_SESSION_WARNING_SECONDS) {
+ return 'text-yellow-200 bg-yellow-950/80 border-yellow-500/40';
+ }
+
+ return 'text-neutral-300 bg-black/70 border-white/10';
},
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 = '';
this.pendingCommand = null;
+ this.resetTerminalSessionCountdown();
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
@@ -276,10 +358,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
@@ -303,6 +397,7 @@ export function initializeTerminalComponent() {
this.connectionState = 'disconnected';
this.clearAllTimers();
+ this.resetTerminalSessionCountdown();
// Only reset terminal and reconnect if it wasn't a clean close
if (event.code !== 1000) {
@@ -354,6 +449,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 +466,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,9 +483,18 @@ 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.startTerminalSessionCountdown();
this.term.focus();
document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded-sm');
@@ -415,14 +520,20 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
+ this.lastSentCommand = null;
+ this.resetTerminalSessionCountdown();
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'pty-exited') {
+ this.fullscreen = false;
+ this.mobileToolbarCollapsed = false;
this.terminalActive = false;
+ this.resetTerminalSessionCountdown();
this.term.reset();
this.commandBuffer = '';
+ this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
@@ -433,6 +544,7 @@ export function initializeTerminalComponent() {
logTerminal('error', '[Terminal] Backend rejected terminal startup:', event.data);
this.$wire.dispatch('error', event.data);
this.terminalActive = false;
+ this.resetTerminalSessionCountdown();
} else {
try {
this.pendingWrites++;
@@ -493,12 +605,65 @@ export function initializeTerminalComponent() {
});
},
- keepAlive() {
- // Skip keepalive when document is hidden to prevent unnecessary disconnects
- if (!this.isDocumentVisible) {
+
+ sendTerminalInput(data) {
+ if (!this.term || !this.terminalActive) {
return;
}
+ this.term.focus();
+ this.sendMessage({ message: data });
+ },
+
+ sendTerminalControl(sequence) {
+ const terminalSequences = {
+ arrowUp: '\x1b[A',
+ arrowDown: '\x1b[B',
+ arrowRight: '\x1b[C',
+ arrowLeft: '\x1b[D',
+ tab: '\t',
+ escape: '\x1b',
+ ctrlC: '\x03'
+ };
+
+ if (terminalSequences[sequence]) {
+ this.sendTerminalInput(terminalSequences[sequence]);
+ }
+ },
+
+ async pasteFromClipboard() {
+ if (!navigator.clipboard?.readText) {
+ this.$wire.dispatch('error', 'Clipboard paste is not available in this browser.');
+ return;
+ }
+
+ try {
+ const text = await navigator.clipboard.readText();
+ if (text) {
+ this.sendTerminalInput(text);
+ }
+ } catch (error) {
+ logTerminal('warn', '[Terminal] Clipboard paste failed:', error);
+ this.$wire.dispatch('error', 'Clipboard paste permission was denied.');
+ }
+ },
+
+ async copyTerminalSelection() {
+ const selection = this.term?.getSelection();
+ if (!selection) {
+ this.$wire.dispatch('error', 'Select terminal text before copying.');
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(selection);
+ } catch (error) {
+ logTerminal('warn', '[Terminal] Clipboard copy failed:', error);
+ this.$wire.dispatch('error', 'Clipboard copy permission was denied.');
+ }
+ },
+
+ keepAlive() {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -524,10 +689,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;
@@ -576,15 +754,20 @@ export function initializeTerminalComponent() {
// Force a refresh of the fit addon dimensions
this.fitAddon.fit();
- // Get fresh dimensions after fit
- const wrapperHeight = this.$refs.terminalWrapper.clientHeight;
- const wrapperWidth = this.$refs.terminalWrapper.clientWidth;
+ // Get fresh dimensions from the terminal element itself. The mobile
+ // toolbar can live beside the terminal in normal flow, so wrapper dimensions
+ // would include controls that should not be counted as terminal rows.
+ const terminalElement = document.getElementById('terminal');
+ const terminalHeight = terminalElement?.clientHeight || this.$refs.terminalWrapper.clientHeight;
+ const terminalWidth = terminalElement?.clientWidth || this.$refs.terminalWrapper.clientWidth;
- // Account for terminal container padding (px-2 py-1 = 8px left/right, 4px top/bottom)
- const horizontalPadding = 16; // 8px * 2 (left + right)
- const verticalPadding = 8; // 4px * 2 (top + bottom)
- const height = wrapperHeight - verticalPadding;
- const width = wrapperWidth - horizontalPadding;
+ // Account for terminal container padding. In fullscreen mobile mode,
+ // the fixed toolbar sits over the terminal container, so reserve its height
+ // when calculating rows to keep the prompt above the controls.
+ const horizontalPadding = 16; // px-2 = 8px * 2 (left + right)
+ const verticalPadding = 8; // py-1 = 4px * 2 (top + bottom)
+ const height = terminalHeight - verticalPadding;
+ const width = terminalWidth - horizontalPadding;
// Check if dimensions are valid
if (height <= 0 || width <= 0) {
diff --git a/resources/js/terminal.test.js b/resources/js/terminal.test.js
new file mode 100644
index 000000000..e0a4fb852
--- /dev/null
+++ b/resources/js/terminal.test.js
@@ -0,0 +1,15 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ MAX_TERMINAL_SESSION_SECONDS,
+ formatTerminalSessionRemainingTime,
+} from './terminal-session-timer.js';
+
+test('formatTerminalSessionRemainingTime formats the eight hour terminal limit countdown', () => {
+ assert.equal(MAX_TERMINAL_SESSION_SECONDS, 8 * 60 * 60);
+ assert.equal(formatTerminalSessionRemainingTime(MAX_TERMINAL_SESSION_SECONDS), '8h 00m 00s');
+ assert.equal(formatTerminalSessionRemainingTime((7 * 60 * 60) + (59 * 60) + 59), '7h 59m 59s');
+ assert.equal(formatTerminalSessionRemainingTime(65 * 60), '1h 05m 00s');
+ assert.equal(formatTerminalSessionRemainingTime(59), '0m 59s');
+ assert.equal(formatTerminalSessionRemainingTime(0), 'expired');
+});
diff --git a/resources/views/components/database-status-info.blade.php b/resources/views/components/database-status-info.blade.php
new file mode 100644
index 000000000..4a9de3ca5
--- /dev/null
+++ b/resources/views/components/database-status-info.blade.php
@@ -0,0 +1,94 @@
+@props([
+ 'database',
+ 'label',
+ 'dbUrl' => null,
+ 'dbUrlPublic' => null,
+ 'supportsSsl' => true,
+ 'enableSsl' => false,
+ 'sslMode' => null,
+ 'sslModeOptions' => null,
+ 'sslModeHelper' => null,
+ 'certificateValidUntil' => null,
+ 'isExited' => false,
+ 'showPublicUrlPlaceholder' => false,
+])
+
+@php
+ $urlHelper = 'If you change the user/password/port, this could be different. This is with the default values.';
+@endphp
+
+
+
+ @if ($dbUrlPublic)
+
+ @elseif ($showPublicUrlPlaceholder)
+
+ @endif
+
+ @if ($supportsSsl)
+
+
+
+
SSL Configuration
+ @if ($enableSsl && $certificateValidUntil)
+
+ @endif
+
+
+ @if ($enableSsl && $certificateValidUntil)
+
Valid until:
+ @if (now()->gt($certificateValidUntil))
+ {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expired
+ @elseif(now()->addDays(30)->gt($certificateValidUntil))
+ {{ $certificateValidUntil->format('d.m.Y H:i:s') }} - Expiring
+ soon
+ @else
+ {{ $certificateValidUntil->format('d.m.Y H:i:s') }}
+ @endif
+
+ @endif
+
+
+ @if ($isExited)
+
+ @else
+
+ @endif
+
+ @if ($sslModeOptions && $enableSsl)
+
+ @if ($isExited)
+
+ @foreach ($sslModeOptions as $value => $option)
+
+ @endforeach
+
+ @else
+
+ @foreach ($sslModeOptions as $value => $option)
+
+ @endforeach
+
+ @endif
+
+ @endif
+
+
+ @endif
+
diff --git a/resources/views/components/deployment/configuration-diff.blade.php b/resources/views/components/deployment/configuration-diff.blade.php
new file mode 100644
index 000000000..6aac5af4d
--- /dev/null
+++ b/resources/views/components/deployment/configuration-diff.blade.php
@@ -0,0 +1,111 @@
+@props([
+ 'diff' => null,
+ 'compact' => false,
+])
+
+@php
+ $changes = collect(data_get($diff, 'changes', []))->values()->all();
+ $count = count($changes);
+ $requiresBuild = collect($changes)->contains(fn ($change) => data_get($change, 'impact') === 'build');
+@endphp
+
+@if ($count > 0)
+ $compact,
+ 'text-sm' => ! $compact,
+ ])>
+
+ {{ $count }} configuration {{ $count === 1 ? 'change' : 'changes' }}
+ $requiresBuild,
+ 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300' => ! $requiresBuild,
+ ])>
+ {{ $requiresBuild ? 'Rebuild required' : 'Redeploy required' }}
+
+
+
+ @unless ($compact)
+
+ @foreach (collect($changes)->groupBy('section_label') as $sectionLabel => $sectionChanges)
+
+
+ {{ $sectionLabel }}
+
+
+
+
+ @foreach ($sectionChanges as $change)
+ @php
+ $changeKey = (string) data_get($change, 'key');
+ $expandable = data_get($change, 'expandable', false);
+ $oldDisplay = (string) data_get($change, 'old_display_value');
+ $newDisplay = (string) data_get($change, 'new_display_value');
+ $oldFull = data_get($change, 'old_full_value') ?? $oldDisplay;
+ $newFull = data_get($change, 'new_full_value') ?? $newDisplay;
+ $label = (string) data_get($change, 'label');
+ $labelTruncated = mb_strlen($label) > 20;
+ $rowExpandable = $expandable || $labelTruncated;
+ @endphp
+
+
+ @if ($rowExpandable)
+
+ @else
+ {{ $label }}
+ @endif
+
+
+ @if ($expandable)
+
+ @else
+
{{ $oldDisplay }}
+ @endif
+
+
→
+
+
+ @if ($expandable)
+
+ @else
+
{{ $newDisplay }}
+ @endif
+
+ @if ($rowExpandable)
+
+ @endif
+
+
+ @endforeach
+
+
+
+ @endforeach
+
+ @endunless
+
+@endif
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/copy-button.blade.php b/resources/views/components/forms/copy-button.blade.php
index 12fadc595..eb3f3d8a4 100644
--- a/resources/views/components/forms/copy-button.blade.php
+++ b/resources/views/components/forms/copy-button.blade.php
@@ -1,7 +1,13 @@
-@props(['text'])
+@props(['text', 'label' => null])
-
-
+
+ @if ($label)
+
+ @endif
+
diff --git a/resources/views/components/forms/env-var-input.blade.php b/resources/views/components/forms/env-var-input.blade.php
index 642bbcfb0..976c63b29 100644
--- a/resources/views/components/forms/env-var-input.blade.php
+++ b/resources/views/components/forms/env-var-input.blade.php
@@ -196,6 +196,31 @@
}"
@click.outside="showDropdown = false">
+
merge(['class' => $defaultClass]) }}
+ @required($required)
+ @readonly($readonly)
+ @if ($modelBinding !== 'null')
+ wire:model="{{ $modelBinding }}"
+ 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)
+ @if ($type !== 'password')
+ type="{{ $type }}"
+ @endif
+ @if ($htmlId !== 'null') id="{{ $htmlId }}" @endif
+ name="{{ $name }}"
+ placeholder="{{ $attributes->get('placeholder') }}"
+ @if ($autofocus) autofocus @endif>
+
@if ($type === 'password' && $allowToPeak)