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 @@ ## Project Overview
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
+## Design Reference
+
+For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
+
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.
diff --git a/README.md b/README.md
index 9a5feff4e..b387d87e8 100644
--- a/README.md
+++ b/README.md
@@ -59,8 +59,9 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
+* [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 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
-* [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 @@ ### Big Sponsors
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
+* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions
@@ -151,6 +152,10 @@ ### Small Sponsors
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php
index e86e30f04..b79709c5a 100644
--- a/app/Actions/Application/StopApplication.php
+++ b/app/Actions/Application/StopApplication.php
@@ -36,10 +36,11 @@ public function handle(Application $application, bool $previewDeployments = fals
: getCurrentApplicationContainerStatus($server, $application->id, 0);
$containersToStop = $containers->pluck('Names')->toArray();
+ $timeout = $application->settings->stopGracePeriodSeconds();
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
- "docker stop -t 30 $containerName",
+ "docker stop --time=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}
diff --git a/app/Actions/Application/StopApplicationOneServer.php b/app/Actions/Application/StopApplicationOneServer.php
index bf9fdee72..09de9b628 100644
--- a/app/Actions/Application/StopApplicationOneServer.php
+++ b/app/Actions/Application/StopApplicationOneServer.php
@@ -20,13 +20,15 @@ public function handle(Application $application, Server $server)
}
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 30cae71f1..525e736c3 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -50,13 +50,9 @@ public function handle(StandaloneClickhouse $database)
],
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
- 'interval' => '5s',
- 'timeout' => '5s',
- 'retries' => 10,
- '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 @@ public function handle(StandaloneClickhouse $database)
$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\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 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
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\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 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
$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 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
?? 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 addc30be4..b78a0987d 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -106,13 +106,9 @@ public function handle(StandaloneDragonfly $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'redis-cli', '-a', (string) $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 @@ public function handle(StandaloneDragonfly $database)
$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 e59d6f697..89258fe24 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -108,13 +108,9 @@ public function handle(StandaloneKeydb $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'keydb-cli', '--pass', (string) $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,
@@ -197,6 +193,9 @@ public function handle(StandaloneKeydb $database)
// 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 ceb1e8b85..2e8faea9a 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -103,13 +103,9 @@ public function handle(StandaloneMariadb $database)
$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,
@@ -202,6 +198,9 @@ public function handle(StandaloneMariadb $database)
];
}
+ 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 c79789718..80ec812a1 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -109,17 +109,11 @@ public function handle(StandaloneMongodb $database)
$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 @@ public function handle(StandaloneMongodb $database)
$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";
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 0394d50b6..0445bddcd 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -103,13 +103,9 @@ public function handle(StandaloneMysql $database)
$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,
@@ -203,6 +199,9 @@ public function handle(StandaloneMysql $database)
];
}
+ 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/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index da8b5dc4e..ae7ae9860 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -110,13 +110,9 @@ public function handle(StandalonePostgresql $database)
$this->database->destination->network,
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
- 'healthcheck' => [
- 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 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,
@@ -213,6 +209,9 @@ public function handle(StandalonePostgresql $database)
$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";
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index c31b099e4..64b434821 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -105,17 +105,11 @@ public function handle(StandaloneRedis $database)
$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,
@@ -194,6 +188,9 @@ public function handle(StandaloneRedis $database)
$docker_run_options = convertDockerRunToCompose($this->database->custom_docker_run_options);
$docker_compose = generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $this->database->destination->network);
+ if (! $this->database->isHealthcheckEnabled()) {
+ unset($docker_compose['services'][$container_name]['healthcheck']);
+ }
$docker_compose = Yaml::dump($docker_compose, 10);
$docker_compose_base64 = base64_encode($docker_compose);
$this->commands[] = "echo '{$docker_compose_base64}' | base64 -d | tee $this->configuration_dir/docker-compose.yml > /dev/null";
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
index 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 @@ public function create(array $input): User
'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 98cce088b..06abeb3a6 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -51,6 +51,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
+ "docker 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 @@ public function handle(Server $server)
"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 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
-use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
- $token = data_get($server, 'settings.sentinel_token');
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
- }
+ $token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
diff --git a/app/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 @@
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 @@ public function handle(Service $service, bool $pullLatestImages = false, bool $s
$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 @@ public function execute(): array
// 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 @@ public function execute(): array
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 @@ public function handle()
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 @@ private function restoreCoolifyDbBackup()
'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 @@ public function handle()
$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 @@ public function handle()
$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 @@ public function handle()
}
}
- // 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 @@ public function handle()
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 @@ public function handle()
$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 @@ public function handle()
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 @@ public function handle()
$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 @@ public function handle()
$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/Kernel.php b/app/Console/Kernel.php
index 75ec31ae0..e6dc32383 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -8,6 +8,7 @@
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;
@@ -40,7 +41,10 @@ protected function schedule(Schedule $schedule): void
$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();
@@ -78,7 +82,7 @@ protected function schedule(Schedule $schedule): void
// 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 @@
use App\Models\InstanceSettings;
use App\Models\User;
+use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
+use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
- * @var array, \Psr\Log\LogLevel::*>
+ * @var array, LogLevel::*>
*/
protected $levels = [
//
@@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
- * @var array>
+ * @var array>
*/
protected $dontReport = [
ProcessException::class,
@@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $exception->getMessage(),
+ 'guards' => $exception->guards(),
+ ], 'warning');
+ }
+
return response()->json(['message' => $exception->getMessage()], 401);
}
@@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
- if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
+ if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
+ if ($request->is('api/*')) {
+ auditLog('api.auth.policy_denied', [
+ 'reason' => $e->getMessage(),
+ 'route' => $request->route()?->getName() ?? $request->path(),
+ ], 'warning');
+ }
+
// Get the custom message from the policy if available
$message = $e->getMessage();
diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php
index aa9d06996..907cb4456 100644
--- a/app/Helpers/SshMultiplexingHelper.php
+++ b/app/Helpers/SshMultiplexingHelper.php
@@ -4,6 +4,7 @@
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 @@
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 @@ public static function ensureMultiplexedConnection(Server $server): bool
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 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
- $connectionTimeout = config('constants.ssh.connection_timeout');
+ $connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
- $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 @@ public static function generateSshCommand(Server $server, string $command, bool
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 @@ private static function validateSshKey(PrivateKey $privateKey): void
$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 @@ private static function getCommonSshOptions(Server $server, string $sshKeyLocati
.'-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 eb2e7fc53..5e5405a7a 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -5,7 +5,6 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
-use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
@@ -18,7 +17,7 @@
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;
@@ -147,7 +146,7 @@ public function applications(Request $request)
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.'],
@@ -155,7 +154,7 @@ public function applications(Request $request)
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -313,7 +312,7 @@ public function create_public_application(Request $request)
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.'],
@@ -324,7 +323,7 @@ public function create_public_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -479,7 +478,7 @@ public function create_private_gh_app_application(Request $request)
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.'],
@@ -490,7 +489,7 @@ public function create_private_gh_app_application(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -652,7 +651,7 @@ public function create_private_deploy_key_application(Request $request)
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -782,7 +781,7 @@ public function create_dockerfile_application(Request $request)
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.'],
@@ -899,105 +898,6 @@ public function create_dockerimage_application(Request $request)
return $this->create_application($request, 'dockerimage');
}
- /**
- * @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
- */
- #[OA\Post(
- summary: 'Create (Docker Compose)',
- description: 'Deprecated: Use POST /api/v1/services instead.',
- path: '/applications/dockercompose',
- operationId: 'create-dockercompose-application',
- deprecated: true,
- security: [
- ['bearerAuth' => []],
- ],
- tags: ['Applications'],
- requestBody: new OA\RequestBody(
- description: 'Application object that needs to be created.',
- required: true,
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
- properties: [
- 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
- 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
- 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
- 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
- 'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
- 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
- 'name' => ['type' => 'string', 'description' => 'The application name.'],
- 'description' => ['type' => 'string', 'description' => 'The application description.'],
- 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
- 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
- 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
- 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
- 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
- ],
- )
- ),
- ]
- ),
- responses: [
- new OA\Response(
- response: 201,
- description: 'Application created successfully.',
- content: new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'uuid' => ['type' => 'string'],
- ]
- )
- )
- ),
- new OA\Response(
- response: 401,
- ref: '#/components/responses/401',
- ),
- new OA\Response(
- response: 400,
- ref: '#/components/responses/400',
- ),
- new OA\Response(
- response: 409,
- description: 'Domain conflicts detected.',
- content: [
- new OA\MediaType(
- mediaType: 'application/json',
- schema: new OA\Schema(
- type: 'object',
- properties: [
- 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
- 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
- 'conflicts' => [
- 'type' => 'array',
- 'items' => new OA\Schema(
- type: 'object',
- properties: [
- 'domain' => ['type' => 'string', 'example' => 'example.com'],
- 'resource_name' => ['type' => 'string', 'example' => 'My Application'],
- 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
- 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
- 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
- ]
- ),
- ],
- ]
- )
- ),
- ]
- ),
- ]
- )]
- public function create_dockercompose_application(Request $request)
- {
- return $this->create_application($request, 'dockercompose');
- }
-
private function create_application(Request $request, $type)
{
$teamId = getTeamIdFromToken();
@@ -1080,6 +980,9 @@ private function create_application(Request $request, $type)
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@@ -1121,7 +1024,7 @@ private function create_application(Request $request, $type)
'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',
@@ -1309,6 +1212,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1318,7 +1230,7 @@ private function create_application(Request $request, $type)
'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',
@@ -1539,6 +1451,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1549,7 +1470,7 @@ private function create_application(Request $request, $type)
'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',
@@ -1739,6 +1660,15 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@@ -1846,15 +1776,24 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === '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);
@@ -1956,93 +1895,19 @@ private function create_application(Request $request, $type)
}
}
+ auditLog('api.application.created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => data_get($application, 'uuid'),
+ 'application_name' => data_get($application, 'name'),
+ 'application_type' => $type,
+ 'build_pack' => data_get($application, 'build_pack'),
+ 'instant_deploy' => (bool) ($instantDeploy ?? false),
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
- } elseif ($type === 'dockercompose') {
- $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
-
- $extraFields = array_diff(array_keys($request->all()), $allowedFields);
- if ($validator->fails() || ! empty($extraFields)) {
- $errors = $validator->errors();
- if (! empty($extraFields)) {
- foreach ($extraFields as $field) {
- $errors->add($field, 'This field is not allowed.');
- }
- }
-
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $errors,
- ], 422);
- }
- if (! $request->has('name')) {
- $request->offsetSet('name', 'service'.new Cuid2);
- }
- $validationRules = [
- 'docker_compose_raw' => 'string|required',
- ];
- $validationRules = array_merge(sharedDataApplications(), $validationRules);
- $validator = customApiValidator($request->all(), $validationRules);
-
- if ($validator->fails()) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $validator->errors(),
- ], 422);
- }
- $return = $this->validateDataApplications($request, $server);
- if ($return instanceof JsonResponse) {
- return $return;
- }
- if (! isBase64Encoded($request->docker_compose_raw)) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerComposeRaw = base64_decode($request->docker_compose_raw);
- if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => [
- 'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
- ],
- ], 422);
- }
- $dockerCompose = base64_decode($request->docker_compose_raw);
- $dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
-
- $service = new Service;
- removeUnnecessaryFieldsFromRequest($request);
- $service->fill($request->only($allowedFields));
-
- $service->docker_compose_raw = $dockerComposeRaw;
- $service->environment_id = $environment->id;
- $service->server_id = $server->id;
- $service->destination_id = $destination->id;
- $service->destination_type = $destination->getMorphClass();
- if (isset($isContainerLabelEscapeEnabled)) {
- $service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
- }
- $service->save();
-
- $service->parse(isNew: true);
-
- // Apply service-specific application prerequisites
- applyServiceApplicationPrerequisites($service);
-
- if ($instantDeploy) {
- StartService::dispatch($service);
- }
-
- return response()->json(serializeApiResponse([
- 'uuid' => data_get($service, 'uuid'),
- 'domains' => data_get($service, 'domains'),
- ]))->setStatusCode(201);
}
return response()->json(['message' => 'Invalid type.'], 400);
@@ -2297,6 +2162,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.application.deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
return response()->json([
'message' => 'Application deletion request queued.',
]);
@@ -2339,7 +2210,7 @@ public function delete_by_uuid(Request $request)
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
- 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
+ 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -2530,7 +2401,7 @@ public function update_by_uuid(Request $request)
}
}
}
- 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.',
@@ -2548,6 +2419,9 @@ public function update_by_uuid(Request $request)
],
], 422);
}
+ $request->merge([
+ 'custom_nginx_configuration' => $customNginxConfiguration,
+ ]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -2796,6 +2670,13 @@ public function update_by_uuid(Request $request)
}
$application->save();
+ auditLog('api.application.updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@@ -3048,6 +2929,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3081,6 +2970,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.application.env_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@@ -3307,6 +3204,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.application.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_count' => $returnedEnvs->count(),
+ ]);
+
return response()->json($returnedEnvs)->setStatusCode(201);
}
@@ -3446,6 +3349,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3471,6 +3382,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
+ auditLog('api.application.env_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ 'is_preview' => (bool) $is_preview,
+ ]);
+
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@@ -3562,8 +3481,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.',
], 404);
}
+ $envKey = $found_env->key;
+ $envUuid = $found_env->uuid;
$found_env->forceDelete();
+ auditLog('api.application.env_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json([
'message' => 'Environment variable deleted.',
]);
@@ -3675,6 +3603,15 @@ public function action_deploy(Request $request)
);
}
+ auditLog('api.application.deployed', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'force_rebuild' => $force,
+ 'instant_deploy' => $instant_deploy,
+ ]);
+
return response()->json(
[
'message' => 'Deployment request queued.',
@@ -3763,6 +3700,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
+ auditLog('api.application.stopped', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Application stopping request queued.',
@@ -3853,6 +3797,13 @@ public function action_restart(Request $request)
], 200);
}
+ auditLog('api.application.restarted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ ]);
+
return response()->json(
[
'message' => 'Restart request queued.',
@@ -4221,6 +4172,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.application.storage_updated', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -4399,6 +4359,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.application.storage_created', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -4472,8 +4441,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.application.storage_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
@@ -4543,6 +4522,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+ auditLog('api.application.preview_deleted', [
+ 'team_id' => $teamId,
+ 'application_uuid' => $application->uuid,
+ 'pull_request_id' => $pullRequestId,
+ ]);
+
return response()->json(['message' => 'Preview deletion request queued.']);
}
}
diff --git a/app/Http/Controllers/Api/CloudProviderTokensController.php b/app/Http/Controllers/Api/CloudProviderTokensController.php
index 5be82a31c..d652f2ba1 100644
--- a/app/Http/Controllers/Api/CloudProviderTokensController.php
+++ b/app/Http/Controllers/Api/CloudProviderTokensController.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -244,7 +245,7 @@ public function store(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'],
]);
+ auditLog('api.cloud_token.created', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $cloudProviderToken->uuid,
+ 'cloud_token_name' => $cloudProviderToken->name,
+ 'provider' => $cloudProviderToken->provider,
+ ]);
+
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@@ -355,7 +363,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields)));
+ auditLog('api.cloud_token.updated', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $token->uuid,
+ 'cloud_token_name' => $token->name,
+ 'provider' => $token->provider,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
+ ]);
+
return response()->json([
'uuid' => $token->uuid,
]);
@@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
+ $tokenUuid = $token->uuid;
+ $tokenName = $token->name;
+ $tokenProvider = $token->provider;
$token->delete();
+ auditLog('api.cloud_token.deleted', [
+ 'team_id' => $teamId,
+ 'cloud_token_uuid' => $tokenUuid,
+ 'cloud_token_name' => $tokenName,
+ 'provider' => $tokenProvider,
+ ]);
+
return response()->json(['message' => 'Cloud provider token deleted.']);
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index c05af152f..bceef4d39 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -299,6 +299,11 @@ public function database_by_uuid(Request $request)
'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],
],
),
)
@@ -565,9 +570,17 @@ public function update_by_uuid(Request $request)
}
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 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database);
}
+ auditLog('api.database.updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'message' => 'Database updated.',
]);
@@ -639,10 +660,10 @@ public function update_by_uuid(Request $request)
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +724,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -826,6 +847,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'frequency' => $backupConfig->frequency,
+ 'save_s3' => (bool) $backupConfig->save_s3,
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@@ -878,10 +908,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +963,10 @@ public function update_backup(Request $request)
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -1045,6 +1075,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
+ auditLog('api.database.backup_updated', [
+ 'team_id' => $teamId,
+ 'backup_uuid' => $backupConfig->uuid,
+ 'database_id' => $backupConfig->database_id,
+ 'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
+ 'backup_now' => (bool) $request->backup_now,
+ ]);
+
return response()->json([
'message' => 'Database backup configuration updated',
]);
@@ -1779,6 +1817,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -1838,6 +1886,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -1897,6 +1955,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -1953,6 +2021,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -2039,6 +2117,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -2075,6 +2163,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} 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'];
@@ -2133,6 +2231,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
+ auditLog('api.database.created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $type->value,
+ 'server_uuid' => $serverUuid,
+ 'is_public' => (bool) $database->is_public,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@@ -2217,6 +2325,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.database.deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json([
'message' => 'Database deletion request queued.',
]);
@@ -2329,6 +2444,14 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete();
DB::commit();
+ auditLog('api.database.backup_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'delete_s3' => $deleteS3,
+ 'executions_deleted' => $executions->count(),
+ ]);
+
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@@ -2451,6 +2574,14 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete();
+ auditLog('api.database.backup_execution_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'backup_uuid' => $request->scheduled_backup_uuid,
+ 'execution_uuid' => $request->execution_uuid,
+ 'delete_s3' => $deleteS3,
+ ]);
+
return response()->json([
'message' => 'Backup execution deleted.',
]);
@@ -2633,6 +2764,13 @@ public function action_deploy(Request $request)
}
StartDatabase::dispatch($database);
+ auditLog('api.database.started', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database starting request queued.',
@@ -2724,6 +2862,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
+ auditLog('api.database.stopped', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Database stopping request queued.',
@@ -2801,6 +2947,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database);
+ auditLog('api.database.restarted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'database_name' => $database->name,
+ 'database_type' => $database->type(),
+ ]);
+
return response()->json(
[
'message' => 'Database restarting request queued.',
@@ -3017,6 +3170,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.database.env_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3145,6 +3305,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
+ auditLog('api.database.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -3266,6 +3432,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.database.env_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@@ -3351,8 +3524,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.database.env_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -3599,6 +3781,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.database.storage_created', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -3797,6 +3988,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.database.storage_updated', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -3870,8 +4070,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.database.storage_deleted', [
+ 'team_id' => $teamId,
+ 'database_uuid' => $database->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/DeployController.php b/app/Http/Controllers/Api/DeployController.php
index 6ff06c10a..c93731d68 100644
--- a/app/Http/Controllers/Api/DeployController.php
+++ b/app/Http/Controllers/Api/DeployController.php
@@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
+ auditLog('api.deployment.cancelled', [
+ 'team_id' => $teamId,
+ 'deployment_uuid' => $deployment->deployment_uuid,
+ 'application_id' => $application?->id,
+ 'application_uuid' => $application?->uuid,
+ 'server_id' => $deployment->server_id,
+ ]);
+
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
+ auditLog('api.deployment.triggered', [
+ 'resource_type' => 'application',
+ 'application_uuid' => $resource->uuid,
+ 'application_name' => $resource->name,
+ 'deployment_uuid' => $deployment_uuid?->toString(),
+ 'force_rebuild' => $force,
+ 'pull_request_id' => $pr,
+ ]);
}
break;
case Service::class:
@@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient.";
+ auditLog('api.service.deployed', [
+ 'service_uuid' => $resource->uuid,
+ 'service_name' => $resource->name,
+ ]);
break;
default:
// Database resource - check authorization
@@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
+ auditLog('api.database.started', [
+ 'database_uuid' => $resource->uuid,
+ 'database_name' => $resource->name,
+ 'database_type' => $resource->getMorphClass(),
+ ]);
break;
}
diff --git a/app/Http/Controllers/Api/GithubController.php b/app/Http/Controllers/Api/GithubController.php
index 9a2cf2b9f..651969b97 100644
--- a/app/Http/Controllers/Api/GithubController.php
+++ b/app/Http/Controllers/Api/GithubController.php
@@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload);
+ auditLog('api.github_app.created', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ ]);
+
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app
$githubApp->update($payload);
+ auditLog('api.github_app.updated', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $githubApp->uuid,
+ 'github_app_name' => $githubApp->name,
+ 'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
+ ]);
+
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409);
}
+ $deletedUuid = $githubApp->uuid;
+ $deletedName = $githubApp->name;
$githubApp->delete();
+ auditLog('api.github_app.deleted', [
+ 'team_id' => $teamId,
+ 'github_app_uuid' => $deletedUuid,
+ 'github_app_name' => $deletedName,
+ ]);
+
return response()->json([
'message' => 'GitHub app deleted successfully',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index 092c48594..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\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -550,7 +552,7 @@ public function createServer(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested
if ($request->instant_validate) {
- \App\Actions\Server\ValidateServer::dispatch($server);
+ ValidateServer::dispatch($server);
}
+ auditLog('api.hetzner_server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'hetzner_server_id' => $hetznerServer['id'],
+ 'ip' => $ipAddress,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 49468b597..f17a4e46b 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
+ auditLog('api.instance.enabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API enabled.'], 200);
}
@@ -137,14 +141,130 @@ public function disable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
+ auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
+
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
+ auditLog('api.instance.disabled', ['team_id' => $teamId]);
+
return response()->json(['message' => 'API disabled.'], 200);
}
+ #[OA\Post(
+ summary: 'Enable MCP Server',
+ description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/enable',
+ operationId: 'enable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server enabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to enable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function enable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => true]);
+
+ auditLog('api.mcp.enabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server enabled.'], 200);
+ }
+
+ #[OA\Post(
+ summary: 'Disable MCP Server',
+ description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
+ path: '/mcp/disable',
+ operationId: 'disable-mcp',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ responses: [
+ new OA\Response(
+ response: 200,
+ description: 'MCP server disabled.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
+ ]
+ )),
+ new OA\Response(
+ response: 403,
+ description: 'You are not allowed to disable the MCP server.',
+ content: new OA\JsonContent(
+ type: 'object',
+ properties: [
+ new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
+ ]
+ )),
+ new OA\Response(
+ response: 401,
+ ref: '#/components/responses/401',
+ ),
+ new OA\Response(
+ response: 400,
+ ref: '#/components/responses/400',
+ ),
+ ]
+ )]
+ public function disable_mcp(Request $request)
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+ if ($teamId !== '0') {
+ auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
+
+ return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
+ }
+ $settings = instanceSettings();
+ $settings->update(['is_mcp_server_enabled' => false]);
+
+ auditLog('api.mcp.disabled', ['team_id' => $teamId]);
+
+ return response()->json(['message' => 'MCP server disabled.'], 200);
+ }
+
public function feedback(Request $request)
{
$data = $request->validate([
diff --git a/app/Http/Controllers/Api/ProjectController.php b/app/Http/Controllers/Api/ProjectController.php
index ec2e300ff..0e5f6e93b 100644
--- a/app/Http/Controllers/Api/ProjectController.php
+++ b/app/Http/Controllers/Api/ProjectController.php
@@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId,
]);
+ auditLog('api.project.created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields));
+ auditLog('api.project.updated', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'project_name' => $project->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
+ $projectUuid = $project->uuid;
+ $projectName = $project->name;
$project->delete();
+ auditLog('api.project.deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $projectUuid,
+ 'project_name' => $projectName,
+ ]);
+
return response()->json(['message' => 'Project deleted.']);
}
@@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name,
]);
+ auditLog('api.project.environment_created', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'environment_name' => $environment->name,
+ ]);
+
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
+ $envUuid = $environment->uuid;
+ $envName = $environment->name;
$environment->delete();
+ auditLog('api.project.environment_deleted', [
+ 'team_id' => $teamId,
+ 'project_uuid' => $project->uuid,
+ 'environment_uuid' => $envUuid,
+ 'environment_name' => $envName,
+ ]);
+
return response()->json(['message' => 'Environment deleted.']);
}
}
diff --git a/app/Http/Controllers/Api/ScheduledTasksController.php b/app/Http/Controllers/Api/ScheduledTasksController.php
index 6245dc2ec..d7b109918 100644
--- a/app/Http/Controllers/Api/ScheduledTasksController.php
+++ b/app/Http/Controllers/Api/ScheduledTasksController.php
@@ -6,6 +6,7 @@
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
+use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
- private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks);
}
- private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save();
+ auditLog('api.scheduled_task.created', [
+ 'team_id' => $teamId,
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json($this->removeSensitiveData($task), 201);
}
- private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
@@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields));
+ auditLog('api.scheduled_task.updated', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $task->uuid,
+ 'task_name' => $task->name,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json($this->removeSensitiveData($task), 200);
}
- private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
- $deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
- if (! $deleted) {
+ $task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
+ if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
+ $taskUuid = $task->uuid;
+ $taskName = $task->name;
+ $task->delete();
+
+ auditLog('api.scheduled_task.deleted', [
+ 'team_id' => getTeamIdFromToken(),
+ 'task_uuid' => $taskUuid,
+ 'task_name' => $taskName,
+ 'resource_type' => $resource instanceof Application ? 'application' : 'service',
+ 'resource_uuid' => $resource->uuid,
+ ]);
+
return response()->json(['message' => 'Scheduled task deleted.']);
}
- private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
+ private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
- public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
- public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
- public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
- public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
- public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
- public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
+ public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
diff --git a/app/Http/Controllers/Api/SecurityController.php b/app/Http/Controllers/Api/SecurityController.php
index 2c62928c2..e59c40866 100644
--- a/app/Http/Controllers/Api/SecurityController.php
+++ b/app/Http/Controllers/Api/SecurityController.php
@@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key,
]);
+ auditLog('api.private_key.created', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $key->uuid,
+ 'private_key_name' => $key->name,
+ 'fingerprint' => $fingerPrint,
+ ]);
+
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@@ -333,6 +340,13 @@ public function update_key(Request $request)
}
$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 @@ public function delete_key(Request $request)
], 422);
}
+ $keyUuid = $key->uuid;
+ $keyName = $key->name;
$key->forceDelete();
+ auditLog('api.private_key.deleted', [
+ 'team_id' => $teamId,
+ 'private_key_uuid' => $keyUuid,
+ 'private_key_name' => $keyName,
+ ]);
+
return response()->json([
'message' => 'Private Key deleted.',
]);
diff --git a/app/Http/Controllers/Api/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\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 @@ public function create_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.created', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'ip' => $server->ip,
+ 'is_build_server' => (bool) $request->is_build_server,
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@@ -639,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
- $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
+ $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -647,7 +657,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
- if ($return instanceof \Illuminate\Http\JsonResponse) {
+ if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
+ 'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
- $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
+ $advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
@@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server);
}
+ auditLog('api.server.updated', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $server->uuid,
+ 'server_name' => $server->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@@ -807,6 +825,9 @@ public function delete_server(Request $request)
}
}
+ $deletedUuid = $server->uuid;
+ $deletedName = $server->name;
+ $deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id
);
+ auditLog('api.server.deleted', [
+ 'team_id' => $teamId,
+ 'server_uuid' => $deletedUuid,
+ 'server_name' => $deletedName,
+ 'ip' => $deletedIp,
+ 'force' => $force,
+ ]);
+
return response()->json(['message' => 'Server deleted.']);
}
@@ -881,6 +910,12 @@ public function validate_server(Request $request)
}
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 20560635e..11a23d46c 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => $oneClickServiceName ?? null,
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'service_type' => 'docker_compose',
+ 'instant_deploy' => (bool) $instantDeploy,
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
+ auditLog('api.service.deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json([
'message' => 'Service deletion request queued.',
]);
@@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service);
}
+ auditLog('api.service.updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
+ ]);
+
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
+ auditLog('api.service.env_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env));
}
+ auditLog('api.service.env_bulk_upserted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_count' => $updatedEnvs->count(),
+ ]);
+
return response()->json($updatedEnvs)->setStatusCode(201);
}
@@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
+ auditLog('api.service.env_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $env->uuid,
+ 'env_key' => $env->key,
+ ]);
+
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
+ $envKey = $env->key;
+ $envUuid = $env->uuid;
$env->forceDelete();
+ auditLog('api.service.env_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'env_uuid' => $envUuid,
+ 'env_key' => $envKey,
+ ]);
+
return response()->json(['message' => 'Environment variable deleted.']);
}
@@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
}
StartService::dispatch($service);
+ auditLog('api.service.deployed', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ ]);
+
return response()->json(
[
'message' => 'Service starting request queued.',
@@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
+ auditLog('api.service.stopped', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'docker_cleanup' => $dockerCleanup,
+ ]);
+
return response()->json(
[
'message' => 'Service stopping request queued.',
@@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
+ auditLog('api.service.restarted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'service_name' => $service->name,
+ 'pull_latest' => $pullLatest,
+ ]);
+
return response()->json(
[
'message' => 'Service restarting request queued.',
@@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
+ auditLog('api.service.storage_created', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path,
+ ]);
+
return response()->json($storage, 201);
}
@@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
+ auditLog('api.service.storage_updated', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storage->uuid ?? null,
+ 'storage_id' => $storage->id,
+ 'storage_type' => $request->type,
+ 'mount_path' => $storage->mount_path ?? null,
+ ]);
+
return response()->json($storage);
}
@@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
+ $storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
+ $storageMountPath = $storage->mount_path ?? null;
$storage->delete();
+ auditLog('api.service.storage_deleted', [
+ 'team_id' => $teamId,
+ 'service_uuid' => $service->uuid,
+ 'storage_uuid' => $storageUuid,
+ 'storage_type' => $storageType,
+ 'mount_path' => $storageMountPath,
+ ]);
+
return response()->json(['message' => 'Storage deleted.']);
}
}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 6ce6b6d57..3090538c3 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -7,6 +7,7 @@
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\Validation\ValidatesRequests;
use Illuminate\Http\Request;
@@ -98,23 +99,50 @@ public function link()
{
$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();
+ $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 @@ public function callback(string $provider)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 96fbd7193..6c3dda402 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -29,6 +29,7 @@ class UploadController extends BaseController
'archive.gz',
'bz2',
'xz',
+ 'dmp',
];
public function upload(Request $request)
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index ffa71b55a..d37ba7cee 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -4,6 +4,8 @@
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 @@
class Bitbucket extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -31,6 +36,16 @@ public function manual(Request $request)
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($payload, 'push.changes.0.new.target.hash');
+ // Bitbucket webhooks ship up to 5 commits per change. Larger pushes
+ // are evaluated only on the visible 5.
+ $skip_deploy_commits = self::shouldSkipDeploy(
+ collect(data_get($payload, 'push.changes', []))
+ ->flatMap(fn ($change) => data_get($change, 'commits', []))
+ ->pluck('message')
+ ->filter()
+ ->values()
+ ->all()
+ );
if (! $branch) {
return response([
@@ -45,10 +60,18 @@ public function manual(Request $request)
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
+ $pull_request_title = data_get($payload, 'pullrequest.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
- $applications = Application::where('git_repository', 'like', "%$full_name%");
- $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',
@@ -58,11 +81,13 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
if (empty($webhook_secret)) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Webhook secret not configured.',
+ 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;
}
@@ -70,22 +95,26 @@ public function manual(Request $request)
$parts = explode('=', $x_bitbucket_token, 2);
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ 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()) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ 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;
}
@@ -101,6 +130,17 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'repo:push') {
if ($application->isDeployable()) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -118,6 +158,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'bitbucket',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => $commit,
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -134,6 +183,15 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
new file mode 100644
index 000000000..69695e99b
--- /dev/null
+++ b/app/Http/Controllers/Webhook/Concerns/DetectsSkipDeployCommits.php
@@ -0,0 +1,55 @@
+ $messages
+ */
+ public static function shouldSkipDeploy(array $messages): bool
+ {
+ $messages = array_values(array_filter($messages, fn ($m) => filled($m)));
+
+ if (empty($messages)) {
+ return false;
+ }
+
+ foreach ($messages as $message) {
+ $lower = strtolower((string) $message);
+ if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns true if at least one non-empty message contains [skip cd] or
+ * [skip ci]. Used for PR/MR title + latest-commit signals where any one
+ * marker should trigger the skip.
+ *
+ * @param array $messages
+ */
+ public static function shouldSkipDeployAny(array $messages): bool
+ {
+ foreach ($messages as $message) {
+ if (! filled($message)) {
+ continue;
+ }
+ $lower = strtolower((string) $message);
+ if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Webhook/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 62adf5410..be064e380 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -4,6 +4,8 @@
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 @@
class Gitea extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -40,27 +45,34 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
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'.");
}
@@ -68,21 +80,25 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Webhook secret not configured.',
+ 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;
}
@@ -100,6 +116,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -117,6 +144,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitea',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -149,6 +185,15 @@ public function manual(Request $request)
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index 4158016d0..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 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$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 @@ public function manual(Request $request)
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'.");
}
@@ -82,21 +97,25 @@ public function manual(Request $request)
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Webhook secret not configured.',
+ 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;
}
@@ -114,6 +133,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -131,6 +161,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@@ -180,11 +219,13 @@ public function manual(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -224,6 +265,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+ auditLogWebhookFailure('github', 'invalid_signature', [
+ 'mode' => 'app',
+ 'github_app_id' => $github_app->id,
+ 'github_app_name' => $github_app->name,
+ 'installation_target_id' => $x_github_hook_installation_target_id,
+ ]);
+
return response('Invalid signature.');
}
}
@@ -246,17 +294,20 @@ public function normal(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
+ $pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
$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.");
@@ -300,6 +351,17 @@ public function normal(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -311,6 +373,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
+ if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'github',
+ 'mode' => 'app',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $result['deployment_uuid'],
+ 'commit' => data_get($payload, 'after'),
+ 'github_app_id' => $github_app->id,
+ ]);
+ }
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@@ -360,11 +433,13 @@ public function normal(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
+ pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
+ isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -382,55 +457,203 @@ public function normal(Request $request)
}
}
+ /**
+ * 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 4453a0e7a..231a0b6e5 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -4,6 +4,8 @@
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 @@
class Gitlab extends Controller
{
+ use DetectsSkipDeployCommits;
+ use MatchesManualWebhookApplications;
+
public function manual(Request $request)
{
try {
@@ -32,6 +37,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
+ auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
+ 'event' => $x_gitlab_event,
+ ]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@@ -58,6 +66,7 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
+ $skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@@ -66,6 +75,9 @@ public function manual(Request $request)
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
+ $pull_request_title = data_get($payload, 'object_attributes.title');
+ $latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
+ $skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@@ -75,9 +87,18 @@ public function manual(Request $request)
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 @@ public function manual(Request $request)
}
}
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',
@@ -101,20 +122,24 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (empty($webhook_secret)) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Webhook secret not configured.',
+ 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 ?? '')) {
- $return_payloads->push([
- 'application' => $application->name,
- 'status' => 'failed',
- 'message' => 'Invalid signature.',
+ 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;
}
@@ -132,6 +157,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
+ if ($skip_deploy_commits ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$result = queue_application_deployment(
application: $application,
@@ -150,6 +186,15 @@ public function manual(Request $request)
'application_name' => $application->name,
]);
} else {
+ auditLog('webhook.deployment.queued', [
+ 'provider' => 'gitlab',
+ 'mode' => 'manual',
+ 'application_uuid' => $application->uuid,
+ 'application_name' => $application->name,
+ 'deployment_uuid' => $deployment_uuid->toString(),
+ 'commit' => data_get($payload, 'after'),
+ 'repository' => $full_name ?? null,
+ ]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@@ -182,6 +227,15 @@ public function manual(Request $request)
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
+ if ($skip_deploy_pr ?? false) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'skipped',
+ 'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
+ ]);
+
+ continue;
+ }
$deployment_uuid = new Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {
diff --git a/app/Http/Controllers/Webhook/Stripe.php b/app/Http/Controllers/Webhook/Stripe.php
index d59adf0ca..41e70b2ce 100644
--- a/app/Http/Controllers/Webhook/Stripe.php
+++ b/app/Http/Controllers/Webhook/Stripe.php
@@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
+use Stripe\Exception\SignatureVerificationException;
+use Stripe\Webhook;
class Stripe extends Controller
{
@@ -14,7 +16,7 @@ public function events(Request $request)
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
- $event = \Stripe\Webhook::constructEvent(
+ $event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
+ } catch (SignatureVerificationException $e) {
+ auditLogWebhookFailure('stripe', 'invalid_signature', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 515d40c62..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 @@ public function handle($request, $next, ...$abilities)
}
return parent::handle($request, $next, ...$abilities);
- } catch (\Illuminate\Auth\AuthenticationException $e) {
+ } catch (AuthenticationException $e) {
+ auditLog('api.auth.unauthenticated', [
+ 'reason' => $e->getMessage(),
+ 'required_abilities' => $abilities,
+ ], 'warning');
+
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
+ auditLog('api.auth.ability_denied', [
+ 'required_abilities' => $abilities,
+ 'token_id' => $request->user()?->currentAccessToken()?->id,
+ 'reason' => $e->getMessage(),
+ ], 'warning');
+
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);
diff --git a/app/Http/Middleware/EnsureMcpEnabled.php b/app/Http/Middleware/EnsureMcpEnabled.php
new file mode 100644
index 000000000..9c4f1339c
--- /dev/null
+++ b/app/Http/Middleware/EnsureMcpEnabled.php
@@ -0,0 +1,25 @@
+is_mcp_server_enabled) {
+ abort(404);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/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
index a8f388c85..e7b34248e 100644
--- a/app/Jobs/ApiTokenExpirationWarningJob.php
+++ b/app/Jobs/ApiTokenExpirationWarningJob.php
@@ -12,7 +12,6 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
-use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
@@ -29,20 +28,36 @@ public function handle(): void
->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;
}
- RateLimiter::attempt(
- 'api-token-expiring:'.$token->id,
- $maxAttempts = 0,
- function () use ($token) {
- Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
- },
- $decaySeconds = 7 * 24 * 3600,
- );
+
+ $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\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 @@ public function tags()
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 @@ public function __construct(public int $application_deployment_queue_id)
$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 @@ private function detectBuildKitCapabilities(): void
if ($majorVersion < 18 || ($majorVersion == 18 && $minorVersion < 9)) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} on {$serverName} does not support BuildKit (requires 18.09+).");
return;
@@ -427,8 +438,11 @@ private function detectBuildKitCapabilities(): void
if (trim($buildxAvailable) === 'available') {
$this->dockerBuildkitSupported = true;
+ $this->dockerBuildxAvailable = true;
$this->application_deployment_queue->addLogEntry("Docker {$dockerVersion} with BuildKit and Buildx detected on {$serverName}.");
} else {
+ $this->dockerBuildxAvailable = false;
+
// Fallback: test DOCKER_BUILDKIT=1 support via --progress flag
$buildkitTest = instant_remote_process(
["DOCKER_BUILDKIT=1 docker build --help 2>&1 | grep -q '\\-\\-progress' && echo 'supported' || echo 'not-supported'"],
@@ -461,6 +475,7 @@ private function detectBuildKitCapabilities(): void
}
} catch (Exception $e) {
$this->dockerBuildkitSupported = false;
+ $this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
@@ -484,8 +499,12 @@ private function decide_what_to_do()
$this->deploy_dockerfile_buildpack();
} elseif ($this->application->build_pack === 'static') {
$this->deploy_static_buildpack();
- } else {
+ } elseif ($this->application->build_pack === 'nixpacks') {
$this->deploy_nixpacks_buildpack();
+ } elseif ($this->application->build_pack === 'railpack') {
+ $this->deploy_railpack_buildpack();
+ } else {
+ throw new DeploymentException("Unsupported build pack: {$this->application->build_pack}");
}
$this->post_deployment();
}
@@ -519,11 +538,6 @@ private function post_deployment()
\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 @@ private function deploy_nixpacks_buildpack()
$this->rolling_update();
}
+ private function deploy_railpack_buildpack(): void
+ {
+ if ($this->use_build_server) {
+ $this->server = $this->build_server;
+ }
+ $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}.");
+ $this->prepare_builder_image();
+ $this->check_git_if_build_needed();
+ $this->generate_image_names();
+ if (! $this->force_rebuild) {
+ $this->check_image_locally_or_remotely();
+ if ($this->should_skip_build()) {
+ return;
+ }
+ }
+ $this->clone_repository();
+ $this->cleanup_git();
+ $this->generate_compose_file();
+
+ // Save build-time .env file BEFORE the build
+ $this->save_buildtime_environment_variables();
+
+ $this->generate_build_env_variables();
+ $this->build_railpack_image();
+
+ // Save runtime environment variables AFTER the build
+ $this->save_runtime_environment_variables();
+ $this->push_to_docker_registry();
+ $this->rolling_update();
+ }
+
private function deploy_static_buildpack()
{
if ($this->use_build_server) {
@@ -1062,7 +1107,7 @@ private function push_to_docker_registry()
'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 @@ private function push_to_docker_registry()
}
}
+ 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 @@ private function generate_image_names()
$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 @@ private function generate_image_names()
}
}
+ 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 @@ private function should_skip_build()
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 @@ private function should_skip_build()
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 @@ private function generate_runtime_environment_variables()
$envs = collect([]);
$sort = $this->application->settings->is_env_sorting_enabled;
if ($sort) {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('key');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('key');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('key');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('key');
} else {
- $sorted_environment_variables = $this->application->environment_variables->sortBy('id');
- $sorted_environment_variables_preview = $this->application->environment_variables_preview->sortBy('id');
+ $sorted_environment_variables = $this->application->runtime_environment_variables->sortBy('id');
+ $sorted_environment_variables_preview = $this->application->runtime_environment_variables_preview->sortBy('id');
}
if ($this->build_pack === 'dockercompose') {
- $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
- 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 @@ private function generate_runtime_environment_variables()
// 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 @@ private function generate_runtime_environment_variables()
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 @@ private function generate_buildtime_environment_variables()
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // 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 @@ private function generate_buildtime_environment_variables()
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
- // 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 @@ private function deploy_pull_request()
if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile();
}
- $this->build_image();
+ if ($this->application->build_pack === 'railpack') {
+ $this->build_railpack_image();
+ } else {
+ $this->build_image();
+ }
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
@@ -2028,21 +2129,23 @@ private function prepare_builder_image(bool $firstTry = true)
$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 @@ private function set_coolify_variables()
}
}
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 @@ private function check_git_if_build_needed()
executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'),
],
[
- executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -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 @@ private function check_git_if_build_needed()
} 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 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
- private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
+ private function generate_railpack_env_variables(): Collection
+ {
+ $variables = $this->railpack_build_variables();
+
+ $this->env_railpack_args = $variables
+ ->map(function ($value, $key) {
+ return '--env '.escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ');
+
+ return $variables;
+ }
+
+ private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
+ {
+ $resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
+ if (is_null($resolvedValue) || $resolvedValue === '') {
+ return null;
+ }
+
+ if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
+ return trim($resolvedValue, "'");
+ }
+
+ return $resolvedValue;
+ }
+
+ /**
+ * All buildtime variables that must reach the Railpack build.
+ *
+ * Railpack's BuildKit frontend treats every `--env` passed to `railpack prepare`
+ * as a build secret entry in the generated plan, then pairs it with `--secret id=,env=`
+ * on `docker buildx build`. Because Railpack's schema disallows top-level `variables`
+ * (unlike Nixpacks, which bakes variables into the plan), this `--env` → `--secret`
+ * channel is the only way user-defined buildtime variables become available to
+ * commands declared with `useSecrets: true`.
+ */
+ private function railpack_build_variables(): Collection
+ {
+ $genericBuildVariables = $this->pull_request_id === 0
+ ? $this->application->environment_variables()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get()
+ : $this->application->environment_variables_preview()->withoutBuildpackControlVariables()->where('is_buildtime', true)->get();
+
+ $railpackVariables = $this->pull_request_id === 0
+ ? $this->application->railpack_environment_variables()->get()
+ : $this->application->railpack_environment_variables_preview()->get();
+
+ $variables = $genericBuildVariables
+ ->merge($railpackVariables)
+ ->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
+ $value = $this->normalize_resolved_build_variable_value($environmentVariable);
+ if (is_null($value) || $value === '') {
+ return [];
+ }
+
+ return [$environmentVariable->key => $value];
+ });
+
+ if ($this->application->install_command) {
+ $variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
+ }
+
+ $variables = $this->merge_railpack_deploy_apt_packages($variables);
+
+ // Mirror Nixpacks behavior: expose COOLIFY_* and SOURCE_COMMIT to the build so apps
+ // (e.g. SPAs baking the public URL) can read them via /run/secrets/.
+ foreach ($this->generate_coolify_env_variables(forBuildTime: true) as $key => $value) {
+ if (! is_null($value) && $value !== '') {
+ $variables->put($key, $value);
+ }
+ }
+
+ return $variables;
+ }
+
+ private function merge_railpack_deploy_apt_packages(Collection $variables): Collection
+ {
+ $packages = collect(preg_split('/\s+/', trim((string) $variables->get('RAILPACK_DEPLOY_APT_PACKAGES', ''))) ?: [])
+ ->filter()
+ ->values();
+
+ foreach (['curl', 'wget'] as $package) {
+ if (! $packages->contains($package)) {
+ $packages->push($package);
+ }
+ }
+
+ $variables->put('RAILPACK_DEPLOY_APT_PACKAGES', $packages->implode(' '));
+
+ return $variables;
+ }
+
+ private function railpack_build_environment_prefix(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return 'env '.$variables
+ ->map(function ($value, $key) {
+ return escapeShellValue("{$key}={$value}");
+ })
+ ->implode(' ').' ';
+ }
+
+ private function railpack_build_secret_flags(Collection $variables): string
+ {
+ if ($variables->isEmpty()) {
+ return '';
+ }
+
+ return ' '.$variables
+ ->map(function ($value, $key) {
+ return '--secret '.escapeShellValue("id={$key},env={$key}");
+ })
+ ->implode(' ');
+ }
+
+ private function railpack_build_command(string $imageName, Collection $variables): string
+ {
+ $cacheArgs = '';
+ if ($this->force_rebuild) {
+ $cacheArgs = '--no-cache';
+ } else {
+ $cacheArgs = "--build-arg cache-key='{$this->application->uuid}'";
+ }
+
+ if ($variables->isNotEmpty()) {
+ $cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
+ }
+
+ $environmentPrefix = $this->railpack_build_environment_prefix($variables);
+ $secretFlags = $this->railpack_build_secret_flags($variables);
+ $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
+
+ return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
+ ." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
+ ." {$this->addHosts} --network host"
+ ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
+ ." {$cacheArgs}"
+ ."{$secretFlags}"
+ .' -f /artifacts/railpack-plan.json'
+ .' --progress plain'
+ .' --load'
+ ." -t {$imageName}"
+ ." {$this->workdir}";
+ }
+
+ private function decode_railpack_config(string $config, string $source): array
+ {
+ try {
+ $decoded = json_decode($config, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Invalid {$source}: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ if (! is_array($decoded)) {
+ throw new DeploymentException("Invalid {$source}: expected a JSON object.");
+ }
+
+ return $decoded;
+ }
+
+ private function is_assoc_array(array $value): bool
+ {
+ if ($value === []) {
+ return false;
+ }
+
+ return array_keys($value) !== range(0, count($value) - 1);
+ }
+
+ private function merge_railpack_config(array $base, array $overrides): array
+ {
+ foreach ($overrides as $key => $value) {
+ if (
+ array_key_exists($key, $base)
+ && is_array($base[$key])
+ && is_array($value)
+ && $this->is_assoc_array($base[$key])
+ && $this->is_assoc_array($value)
+ ) {
+ $base[$key] = $this->merge_railpack_config($base[$key], $value);
+ } else {
+ $base[$key] = $value;
+ }
+ }
+
+ return $base;
+ }
+
+ private function railpack_config_overrides(): array
+ {
+ return [];
+ }
+
+ private function generated_railpack_config_relative_path(): string
+ {
+ return self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generated_railpack_config_absolute_path(): string
+ {
+ return "{$this->workdir}/".self::RAILPACK_GENERATED_CONFIG_PATH;
+ }
+
+ private function generate_railpack_config_file(): ?string
+ {
+ $repositoryConfig = [];
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "test -f {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH." && echo 'exists' || echo 'missing'"),
+ 'hidden' => true,
+ 'save' => 'railpack_config_exists',
+ ]);
+
+ if (str($this->saved_outputs->get('railpack_config_exists'))->trim()->toString() === 'exists') {
+ $this->execute_remote_command([
+ executeInDocker($this->deployment_uuid, "cat {$this->workdir}/".self::RAILPACK_REPOSITORY_CONFIG_PATH),
+ 'hidden' => true,
+ 'save' => 'railpack_repository_config',
+ ]);
+
+ $repositoryConfig = $this->decode_railpack_config(
+ $this->saved_outputs->get('railpack_repository_config', ''),
+ 'repository railpack.json'
+ );
+ }
+
+ $overrides = $this->railpack_config_overrides();
+ if ($repositoryConfig === [] && $overrides === []) {
+ return null;
+ }
+
+ $mergedConfig = $this->merge_railpack_config($repositoryConfig, $overrides);
+ if (! array_key_exists('$schema', $mergedConfig)) {
+ $mergedConfig['$schema'] = 'https://schema.railpack.com';
+ }
+
+ try {
+ $encodedConfig = json_encode($mergedConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ throw new DeploymentException("Failed to encode generated Railpack config: {$exception->getMessage()}", $exception->getCode(), $exception);
+ }
+
+ $configPath = $this->generated_railpack_config_absolute_path();
+ $encodedConfig = base64_encode($encodedConfig);
+
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "mkdir -p {$this->workdir}/.coolify"),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$encodedConfig}' | base64 -d | tee {$configPath} > /dev/null"),
+ 'hidden' => true,
+ ]
+ );
+
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry('Generated Railpack config: '.json_encode($mergedConfig, JSON_PRETTY_PRINT), hidden: true);
+ }
+
+ return $this->generated_railpack_config_relative_path();
+ }
+
+ private function railpack_prepare_command(?string $configFilePath = null): string
+ {
+ $prepare_command = 'railpack prepare';
+
+ if ($this->application->build_command) {
+ $prepare_command .= ' --build-cmd '.escapeShellValue($this->application->build_command);
+ }
+
+ if ($this->application->start_command) {
+ $prepare_command .= ' --start-cmd '.escapeShellValue($this->application->start_command);
+ }
+
+ if ($this->env_railpack_args) {
+ $prepare_command .= " {$this->env_railpack_args}";
+ }
+
+ if ($configFilePath) {
+ $prepare_command .= ' --config-file '.escapeShellValue($configFilePath);
+ }
+
+ $prepare_command .= " --plan-out /artifacts/railpack-plan.json {$this->workdir}";
+
+ return $prepare_command;
+ }
+
+ private function ensure_docker_buildx_available_for_railpack(): void
+ {
+ if ($this->dockerBuildxAvailable) {
+ return;
+ }
+
+ throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
+ }
+
+ private function build_railpack_image(): void
+ {
+ $this->ensure_docker_buildx_available_for_railpack();
+
+ $railpackVariables = $this->generate_railpack_env_variables();
+ $railpackConfigPath = $this->generate_railpack_config_file();
+
+ // Step 1: Generate build plan with railpack prepare
+ $prepare_command = $this->railpack_prepare_command($railpackConfigPath);
+
+ $this->application_deployment_queue->addLogEntry('Generating Railpack build plan.');
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, $prepare_command), 'hidden' => true],
+ [
+ executeInDocker($this->deployment_uuid, 'cat /artifacts/railpack-plan.json'),
+ 'hidden' => true,
+ 'save' => 'railpack_plan',
+ ],
+ );
+
+ $railpackPlanRaw = $this->saved_outputs->get('railpack_plan');
+ if (! empty($railpackPlanRaw)) {
+ if (isDev()) {
+ $this->application_deployment_queue->addLogEntry("Final Railpack plan: {$railpackPlanRaw}", hidden: true);
+ } else {
+ $parsedPlan = json_decode($railpackPlanRaw, true);
+ if (is_array($parsedPlan)) {
+ // Strip secrets array to avoid logging variable names in production.
+ unset($parsedPlan['secrets']);
+ $this->application_deployment_queue->addLogEntry('Final Railpack plan: '.json_encode($parsedPlan, JSON_PRETTY_PRINT), hidden: true);
+ }
+ }
+ }
+
+ // Step 2: Build image using docker buildx with railpack frontend.
+ // Railpack's frontend requires full BuildKit (mergeop), so we use a docker-container driver builder.
+ $this->application_deployment_queue->addLogEntry('Building docker image with Railpack.');
+ $this->application_deployment_queue->addLogEntry('To check the current progress, click on Show Debug Logs.');
+
+ $image_name = $this->application->settings->is_static
+ ? $this->build_image_name
+ : $this->production_image_name;
+
+ if ($this->application->settings->is_static && $this->application->static_image) {
+ $this->pull_latest_image($this->application->static_image);
+ }
+
+ $build_command = $this->railpack_build_command($image_name, $railpackVariables);
+
+ $base64_build_command = base64_encode($build_command);
+ $this->execute_remote_command(
+ [
+ executeInDocker($this->deployment_uuid, "echo '{$base64_build_command}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ],
+ [
+ executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH),
+ 'hidden' => true,
+ ]
+ );
+
+ // Step 3: If static, copy built assets into nginx image
+ if ($this->application->settings->is_static) {
+ $this->build_railpack_static_image();
+ }
+ }
+
+ private function build_railpack_static_image(): void
+ {
+ $publishDir = trim($this->application->publish_directory, '/');
+ $publishDir = $publishDir ? "/{$publishDir}" : '';
+ $dockerfile = base64_encode("FROM {$this->application->static_image}
+WORKDIR /usr/share/nginx/html/
+LABEL coolify.deploymentId={$this->deployment_uuid}
+COPY --from={$this->build_image_name} /app{$publishDir} .
+COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
+
+ if (str($this->application->custom_nginx_configuration)->isNotEmpty()) {
+ $nginx_config = base64_encode($this->application->custom_nginx_configuration);
+ } else {
+ $nginx_config = $this->application->settings->is_spa
+ ? base64_encode(defaultNginxConfiguration('spa'))
+ : base64_encode(defaultNginxConfiguration());
+ }
+
+ $static_build = $this->dockerBuildkitSupported
+ ? "DOCKER_BUILDKIT=1 docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile --progress plain -t {$this->production_image_name} {$this->workdir}"
+ : "docker build {$this->addHosts} --network host -f {$this->workdir}/Dockerfile -t {$this->production_image_name} {$this->workdir}";
+
+ $base64_static_build = base64_encode($static_build);
+ $this->execute_remote_command(
+ [executeInDocker($this->deployment_uuid, "echo '{$dockerfile}' | base64 -d | tee {$this->workdir}/Dockerfile > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$nginx_config}' | base64 -d | tee {$this->workdir}/nginx.conf > /dev/null")],
+ [executeInDocker($this->deployment_uuid, "echo '{$base64_static_build}' | base64 -d | tee ".self::BUILD_SCRIPT_PATH.' > /dev/null'), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'cat '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ [executeInDocker($this->deployment_uuid, 'bash '.self::BUILD_SCRIPT_PATH), 'hidden' => true],
+ );
+ }
+
+ protected function generate_coolify_env_variables(bool $forBuildTime = false): Collection
{
$coolify_envs = collect([]);
$local_branch = $this->branch;
@@ -2538,10 +3054,14 @@ private function generate_env_variables()
// For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) {
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
+ 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 @@ private function generate_env_variables()
}
} 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 @@ private function generate_compose_file()
'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 @@ private function generate_compose_file()
// 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 @@ private function generate_healthcheck_commands()
// 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 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
@@ -3310,14 +3840,15 @@ private function build_image()
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 @@ private function add_build_env_variables_to_dockerfile()
if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build
$envs = $this->application->environment_variables()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@@ -3653,7 +4184,7 @@ private function add_build_env_variables_to_dockerfile()
} else {
// Only add preview environment variables that are available during build
$envs = $this->application->environment_variables_preview()
- ->where('key', 'not like', 'NIXPACKS_%')
+ ->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
foreach ($envs as $env) {
@@ -4257,6 +4788,12 @@ private function handleSuccessfulDeployment(): void
'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\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 @@ public function handle()
{
$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 @@ private function cleanupStaleConnections()
$server = Server::where('uuid', $serverUuid)->first();
if (! $server) {
- $this->removeMultiplexFile($muxFile);
+ $this->removeMultiplexFile($muxFile, 'server_not_found');
continue;
}
@@ -41,14 +168,14 @@ private function cleanupStaleConnections()
$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 @@ private function cleanupNonExistentServerConnections()
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 @@ private function extractServerUuidFromMuxFile($muxFile)
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 @@ private function calculate_size()
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 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
+use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@@ -17,6 +18,7 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
+ use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@@ -31,11 +33,13 @@ public function __construct(
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 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return;
}
+ if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
+ return;
+ }
+
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
- $trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
+ // 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\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\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 @@ public function __construct(public Server $server, public $data)
$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 @@ public function handle()
$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 @@ public function handle()
}
$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 @@ public function handle()
});
$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 @@ public function handle()
$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 @@ private function aggregateMultiContainerStatuses()
}
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 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
continue;
@@ -323,8 +516,6 @@ private function aggregateMultiContainerStatuses()
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
$application->status = $aggregatedStatus;
$application->save();
- } elseif ($aggregatedStatus) {
- $application->update(['last_online_at' => now()]);
}
}
}
@@ -343,7 +534,7 @@ private function aggregateServiceContainerStatuses()
continue;
}
- $service = $this->services->where('id', $serviceId)->first();
+ $service = $this->servicesById->get((string) $serviceId);
if (! $service) {
continue;
}
@@ -351,9 +542,9 @@ private function aggregateServiceContainerStatuses()
// Get the service sub-resource (ServiceApplication or ServiceDatabase)
$subResource = null;
if ($subType === 'application') {
- $subResource = $service->applications->where('id', $subId)->first();
+ $subResource = $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 @@ private function aggregateServiceContainerStatuses()
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
$subResource->status = $aggregatedStatus;
$subResource->save();
- } elseif ($aggregatedStatus) {
- $subResource->update(['last_online_at' => now()]);
}
continue;
@@ -392,39 +581,31 @@ private function aggregateServiceContainerStatuses()
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 @@ private function updateNotFoundApplicationPreviewStatus()
$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 @@ private function updateProxyStatus()
} 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 @@ private function updateProxyStatus()
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 @@ private function updateNotFoundDatabaseStatus()
}
$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\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 @@ public function handle(): void
'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 @@ public function handle(): void
}
}
- 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 @@ private function getTaskRuntimeSkipReason(ScheduledTask $task): ?string
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 @@ private function logSkip(string $type, string $reason, array $context = []): voi
'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 @@ public function handle(): void
$startTime = Carbon::now();
try {
+ $this->initializeExecutionContext();
+
$this->task_log = ScheduledTaskExecution::create([
'scheduled_task_id' => $this->task->id,
'started_at' => $startTime,
@@ -107,8 +118,6 @@ public function handle(): void
// 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 @@ public function handle(): void
// 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 @@ public function backoff(): array
*/
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 @@ public function handle(): void
{
$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 @@ private function disableSshMux(): void
public function handle()
{
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
@@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
+
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
return;
}
}
@@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
+ $wasReachable = (bool) $this->server->settings->is_reachable;
+ $wasNotified = (bool) $this->server->unreachable_notification_sent;
+
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
+ $this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
+
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
+ /**
+ * Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
+ * or when a previously-notified server recovers. Skips noise from single transient flaps.
+ */
+ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
+ {
+ if ($isReachable) {
+ if (! $wasReachable || $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+
+ return;
+ }
+
+ if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
+ ServerReachabilityChanged::dispatch($this->server);
+ }
+ }
+
private function checkHetznerStatus(): void
{
$status = null;
diff --git a/app/Jobs/StripeProcessJob.php b/app/Jobs/StripeProcessJob.php
index 3485ffe32..b031b9c7d 100644
--- a/app/Jobs/StripeProcessJob.php
+++ b/app/Jobs/StripeProcessJob.php
@@ -9,6 +9,7 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Str;
+use Stripe\StripeClient;
class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -35,7 +36,7 @@ public function handle(): void
$data = data_get($this->event, 'data.object');
switch ($type) {
case 'radar.early_fraud_warning.created':
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$id = data_get($data, 'id');
$charge = data_get($data, 'charge');
if ($charge) {
@@ -94,12 +95,12 @@ public function handle(): void
}
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_subscription_id) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$stripeSubscription = $stripe->subscriptions->retrieve(
$subscription->stripe_subscription_id
);
@@ -154,7 +155,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('invoice.payment_failed failed but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found for customer: {$customerId}");
+ break;
}
$team = data_get($subscription, 'team');
if (! $team) {
@@ -165,7 +166,7 @@ public function handle(): void
// Verify payment status with Stripe API before sending failure notification
if ($paymentIntentId) {
try {
- $stripe = new \Stripe\StripeClient(config('subscription.stripe_api_key'));
+ $stripe = new StripeClient(config('subscription.stripe_api_key'));
$paymentIntent = $stripe->paymentIntents->retrieve($paymentIntentId);
if (in_array($paymentIntent->status, ['processing', 'succeeded', 'requires_action', 'requires_confirmation'])) {
@@ -190,7 +191,7 @@ public function handle(): void
$subscription = Subscription::where('stripe_customer_id', $customerId)->first();
if (! $subscription) {
// send_internal_notification('payment_intent.payment_failed, no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
if ($subscription->stripe_invoice_paid) {
// send_internal_notification('payment_intent.payment_failed but invoice is active for customer: '.$customerId);
@@ -334,7 +335,7 @@ public function handle(): void
}
} else {
// send_internal_notification('Subscription deleted but no subscription found in Coolify for customer: '.$customerId);
- throw new \RuntimeException("No subscription found in Coolify for customer: {$customerId}");
+ break;
}
break;
default:
diff --git a/app/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/ForcePasswordReset.php b/app/Livewire/ForcePasswordReset.php
index e6392497f..2463c68e4 100644
--- a/app/Livewire/ForcePasswordReset.php
+++ b/app/Livewire/ForcePasswordReset.php
@@ -47,14 +47,10 @@ public function submit()
try {
$this->rateLimit(10);
$this->validate();
- $firstLogin = auth()->user()->created_at == auth()->user()->updated_at;
auth()->user()->fill([
'password' => Hash::make($this->password),
'force_password_reset' => false,
])->save();
- if ($firstLogin) {
- send_internal_notification('First login for '.auth()->user()->email);
- }
return redirect()->route('dashboard');
} catch (\Throwable $e) {
diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php
index 364163ff8..724dd0bac 100644
--- a/app/Livewire/Notifications/Email.php
+++ b/app/Livewire/Notifications/Email.php
@@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
- public ?string $smtpEncryption = null;
+ public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null;
diff --git a/app/Livewire/Profile/Appearance.php b/app/Livewire/Profile/Appearance.php
new file mode 100644
index 000000000..6a1b72f80
--- /dev/null
+++ b/app/Livewire/Profile/Appearance.php
@@ -0,0 +1,13 @@
+injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
}
+
+ // Load stop_grace_period separately since it has its own save handler
+ // Convert null to empty string to prevent dirty detection issues
+ $this->stopGracePeriod = $this->application->settings->stop_grace_period ?? '';
}
private function resetDefaultLabels()
@@ -210,6 +219,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Settings saved.');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -228,6 +238,7 @@ public function saveCustomName()
if (is_null($this->customInternalName)) {
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
return;
}
@@ -247,6 +258,32 @@ public function saveCustomName()
}
$this->syncData(true);
$this->dispatch('success', 'Custom name saved.');
+ $this->dispatch('configurationChanged');
+ } catch (\Throwable $e) {
+ return handleError($e, $this);
+ }
+ }
+
+ public function saveStopGracePeriod()
+ {
+ try {
+ $this->authorize('update', $this->application);
+
+ $validated = Validator::make(
+ ['stopGracePeriod' => $this->stopGracePeriod === '' ? null : $this->stopGracePeriod],
+ ['stopGracePeriod' => ['nullable', 'integer', 'min:'.MIN_STOP_GRACE_PERIOD_SECONDS, 'max:'.MAX_STOP_GRACE_PERIOD_SECONDS]],
+ [],
+ ['stopGracePeriod' => 'stop grace period']
+ )->validate();
+
+ $this->application->settings->stop_grace_period = $validated['stopGracePeriod'] === null
+ ? null
+ : (int) $validated['stopGracePeriod'];
+ $this->application->settings->save();
+
+ $this->dispatch('success', 'Stop grace period updated.');
+ } catch (ValidationException $e) {
+ throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
diff --git a/app/Livewire/Project/Application/Configuration.php b/app/Livewire/Project/Application/Configuration.php
index cc1bf15b9..fb069f65b 100644
--- a/app/Livewire/Project/Application/Configuration.php
+++ b/app/Livewire/Project/Application/Configuration.php
@@ -17,17 +17,10 @@ class Configuration extends Component
public $servers;
- public function getListeners()
- {
- $teamId = auth()->user()->currentTeam()->id;
-
- return [
- "echo-private:team.{$teamId},ServiceChecked" => '$refresh',
- "echo-private:team.{$teamId},ServiceStatusChanged" => '$refresh',
- 'buildPackUpdated' => '$refresh',
- 'refresh' => '$refresh',
- ];
- }
+ protected $listeners = [
+ 'buildPackUpdated' => '$refresh',
+ 'refresh' => '$refresh',
+ ];
public function mount()
{
@@ -35,7 +28,7 @@ public function mount()
$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 @@ public function mount()
$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 @@ public function getLogLinesProperty()
return decode_remote_command_output($this->application_deployment_queue);
}
- public function copyLogs(): string
- {
- $logs = decode_remote_command_output($this->application_deployment_queue)
- ->map(function ($line) {
- return $line['timestamp'].' '.
- (isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
- trim($line['line']);
- })
- ->join("\n");
-
- return sanitizeLogsForExport($logs);
- }
-
public function downloadAllLogs(): string
{
$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 f89d16912..89b1b4217 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -5,6 +5,7 @@
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 @@ protected function rules(): array
'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 @@ protected function rules(): array
'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',
@@ -211,7 +212,6 @@ protected function messages(): array
'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 @@ public function updatedBuildPack()
// Sync property to model before checking/modifying
$this->syncData(toModel: true);
- if ($this->buildPack !== 'nixpacks') {
+ if ($this->buildPack !== 'nixpacks' && $this->buildPack !== 'railpack') {
$this->isStatic = false;
$this->application->settings->is_static = false;
$this->application->settings->save();
@@ -759,7 +759,7 @@ public function submit($showToaster = true)
$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 @@ public function submit($showToaster = true)
}
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 @@ public function addDockerImagePreview()
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 @@ public function setPrivateKey(int $privateKeyId)
{
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 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Application source updated!');
+ $this->dispatch('configurationChanged');
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -134,8 +140,11 @@ public function changeSource($sourceId, $sourceType)
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 @@ public function delete($password, $selectedActions = [])
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 @@ public function delete($password, $selectedActions = [])
$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 @@ public function delete($password, $selectedActions = [])
} 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 @@ private function customValidate()
$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 @@ private function customValidate()
$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 2583c10ea..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',
];
}
@@ -88,8 +91,6 @@ protected function rules(): array
'publicPort' => 'nullable|integer|min:1|max:65535',
'publicPortTimeout' => 'nullable|integer|min:1',
'customDockerRunOptions' => 'nullable|string',
- 'dbUrl' => 'nullable|string',
- 'dbUrlPublic' => 'nullable|string',
'isLogDrainEnabled' => 'nullable|boolean',
];
}
@@ -129,9 +130,6 @@ public function syncData(bool $toModel = false)
$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;
@@ -144,8 +142,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -194,6 +190,7 @@ public function instantSave()
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);
@@ -202,9 +199,13 @@ public function instantSave()
}
}
- 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()
@@ -220,6 +221,7 @@ public function submit()
}
$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 @@ public function mount()
$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 @@ public function mount()
$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 @@ public function submit()
$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 @@ public function submit()
}
$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 9e1ea0d10..f196b9dfb 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -4,11 +4,9 @@
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 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -98,10 +86,7 @@ protected function rules(): array
'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',
];
}
@@ -137,11 +122,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -153,9 +134,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -204,6 +182,7 @@ public function instantSave()
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);
@@ -212,9 +191,13 @@ public function instantSave()
}
}
- 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()
@@ -230,6 +213,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -241,67 +225,6 @@ public function submit()
}
}
- 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 7c8808499..974803e8d 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -4,11 +4,9 @@
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 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -88,7 +76,7 @@ public function mount()
protected function rules(): array
{
- $baseRules = [
+ return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
@@ -101,13 +89,8 @@ protected function rules(): array
'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
@@ -143,11 +126,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -160,9 +139,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -211,6 +187,7 @@ public function instantSave()
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);
@@ -219,9 +196,13 @@ public function instantSave()
}
}
- 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()
@@ -237,6 +218,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -248,65 +230,6 @@ public function submit()
}
}
- 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 [
@@ -94,7 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
];
}
@@ -133,7 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Options',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -147,12 +123,6 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -176,11 +146,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -196,9 +162,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -234,6 +197,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -270,6 +234,7 @@ public function instantSave()
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);
@@ -278,63 +243,6 @@ public function instantSave()
}
}
- 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 [
@@ -91,8 +67,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@@ -112,7 +86,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
@@ -130,8 +103,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -145,12 +116,6 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -173,12 +138,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -193,10 +153,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -235,6 +191,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -271,6 +228,7 @@ public function instantSave()
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);
@@ -279,68 +237,6 @@ public function instantSave()
}
}
- 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 34726bd0a..6b88d735d 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -4,14 +4,11 @@
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,27 +47,6 @@ 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 [
@@ -96,8 +72,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@@ -118,7 +92,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
@@ -137,8 +110,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -152,12 +123,6 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -181,12 +146,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -202,10 +162,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -241,6 +197,7 @@ public function submit()
}
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -277,6 +234,7 @@ public function instantSave()
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);
@@ -285,68 +243,6 @@ public function instantSave()
}
}
- 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 b5fb85483..4e89e8b62 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -4,14 +4,11 @@
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,32 +51,14 @@ 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
{
@@ -106,8 +85,6 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'enableSsl' => 'boolean',
- 'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@@ -127,7 +104,6 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
- 'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
@@ -148,8 +124,6 @@ protected function messages(): array
'publicPort' => 'Public Port',
'publicPortTimeout' => 'Public Port Timeout',
'customDockerRunOptions' => 'Custom Docker Run Options',
- 'enableSsl' => 'Enable SSL',
- 'sslMode' => 'SSL Mode',
];
public function mount()
@@ -163,12 +137,6 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (Exception $e) {
return handleError($e, $this);
}
@@ -194,12 +162,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -217,10 +180,6 @@ public function syncData(bool $toModel = false)
$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;
}
}
@@ -243,68 +202,6 @@ public function instantSaveAdvanced()
}
}
- 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 {
@@ -330,6 +227,7 @@ public function instantSave()
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);
@@ -493,6 +391,7 @@ public function submit()
}
$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 c3cc43972..aff7b7afa 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -4,14 +4,11 @@
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
{
@@ -87,7 +68,6 @@ protected function rules(): array
'redisPassword' => ValidationPatterns::databasePasswordRules(
enforcePattern: $this->redisPassword !== $this->database->redis_password,
),
- 'enableSsl' => 'boolean',
];
}
@@ -122,7 +102,6 @@ protected function messages(): array
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
- 'enableSsl' => 'Enable SSL',
];
public function mount()
@@ -136,12 +115,6 @@ public function mount()
return;
}
-
- $existingCert = $this->database->sslCertificates()->first();
-
- if ($existingCert) {
- $this->certificateValidUntil = $existingCert->valid_until;
- }
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -161,11 +134,7 @@ public function syncData(bool $toModel = false)
$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;
@@ -177,9 +146,6 @@ public function syncData(bool $toModel = false)
$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;
@@ -227,6 +193,7 @@ public function submit()
);
$this->dispatch('success', 'Database updated.');
+ $this->dispatch('databaseUpdated');
} catch (Exception $e) {
return handleError($e, $this);
} finally {
@@ -259,6 +226,7 @@ public function instantSave()
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);
@@ -267,63 +235,6 @@ public function instantSave()
}
}
- 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 @@ public function delete()
$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/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index b89ce2c6a..737806cb8 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\Project;
use App\Services\DockerImageParser;
+use App\Support\ValidationPatterns;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -81,8 +82,8 @@ public function updatedImageName(): void
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'],
]);
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index 86e407136..1c9c8e896 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -9,6 +9,7 @@
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
@@ -29,6 +30,7 @@ class GithubPrivateRepository extends Component
public int $selected_repository_id;
+ #[Locked]
public int $selected_github_app_id;
public string $selected_repository_owner;
@@ -37,8 +39,6 @@ class GithubPrivateRepository extends Component
public string $selected_branch_name = 'main';
- public string $token;
-
public $repositories;
public int $total_repositories_count = 0;
@@ -71,7 +71,10 @@ public function mount()
$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
@@ -81,9 +84,11 @@ public function updatedSelectedRepositoryId(): void
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
@@ -94,22 +99,25 @@ public function updatedBuildPack()
}
}
- 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']));
}
@@ -140,7 +148,9 @@ public function loadBranches()
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", [
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index 5a6f288b3..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -94,9 +94,11 @@ public function mount()
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->is_static) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->is_static = false;
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index b350538ac..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -96,9 +96,11 @@ public function mount()
public function updatedBuildPack()
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
$this->show_is_static = true;
- $this->port = 3000;
+ if (! $this->isStatic) {
+ $this->port = 3000;
+ }
} elseif ($this->build_pack === 'static') {
$this->show_is_static = false;
$this->isStatic = false;
diff --git a/app/Livewire/Project/Service/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 @@
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 @@ public function mount()
$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 @@ public function restartDatabase($id)
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 @@ public function mount()
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 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
+ if ($this->fileStorage->is_too_large) {
+ return;
+ }
$this->validate();
// Sync to model
@@ -172,6 +175,12 @@ public function submit()
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
+
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$this->authorize('update', $this->resource);
+ if ($this->fileStorage->is_too_large) {
+ $this->dispatch('error', 'File on server is too large to edit from the UI.');
+
+ return;
+ }
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}
diff --git a/app/Livewire/Project/Service/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\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 @@ public function getListeners()
public function checkStatus()
{
+ $this->authorizeService('view');
+
if ($this->service->server->isFunctional()) {
GetContainersStatus::dispatch($this->service->server);
} else {
@@ -61,6 +68,8 @@ public function manualCheckStatus()
public function serviceChecked()
{
+ $this->authorizeService('view');
+
try {
$this->service->applications->each(function ($application) {
$application->refresh();
@@ -82,6 +91,8 @@ public function serviceChecked()
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 @@ public function checkDeployments()
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 @@ public function forceDeploy()
public function stop()
{
+ $this->authorizeService('stop');
+
try {
StopService::dispatch($this->service, false, $this->docker_cleanup);
} catch (\Exception $e) {
@@ -133,6 +150,8 @@ public function stop()
public function restart()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -145,6 +164,8 @@ public function restart()
public function pullAndRestartEvent()
{
+ $this->authorizeService('deploy');
+
$this->checkDeployments();
if ($this->isDeploymentProgress) {
$this->dispatch('error', 'There is a deployment in progress.');
@@ -155,6 +176,15 @@ public function pullAndRestartEvent()
$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 @@ public function mount()
$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 6f43662d5..30655691a 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
- $this->fileStorage = $this->resource->fileStorages()->get();
+ $this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
+ if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
+ $fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
+ }
+ });
$this->resource->load('persistentStorages.resource');
}
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\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 @@ public function getListeners()
];
}
- 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 @@ public function redeploy(int $network_id, int $server_id)
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 @@ public function refreshServers()
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 @@ public function removeServer(int $network_id, int $server_id, $password, $select
}
$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 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -116,12 +129,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -131,7 +144,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -141,7 +154,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -149,7 +162,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -160,7 +173,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -168,7 +181,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -179,7 +192,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -192,6 +205,7 @@ public function availableSharedVariables(): array
public function submit()
{
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
$this->validate();
$this->dispatch('saveKey', [
'key' => $this->key,
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index 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 @@ public function mount()
$this->is_env_sorting_enabled = data_get($this->resource, 'settings.is_env_sorting_enabled', false);
$this->use_build_secrets = data_get($this->resource, 'settings.use_build_secrets', false);
$this->resourceClass = get_class($this->resource);
- $resourceWithPreviews = [\App\Models\Application::class];
+ $resourceWithPreviews = [Application::class];
$simpleDockerfile = filled(data_get($this->resource, 'dockerfile'));
if (str($this->resourceClass)->contains($resourceWithPreviews) && ! $simpleDockerfile) {
$this->showPreview = true;
@@ -194,7 +196,7 @@ public function submit($data = null)
private function updateOrder()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$order = 1;
foreach ($variables as $key => $value) {
$env = $this->resource->environment_variables()->where('key', $key)->first();
@@ -206,7 +208,7 @@ private function updateOrder()
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
$order = 1;
foreach ($previewVariables as $key => $value) {
$env = $this->resource->environment_variables_preview()->where('key', $key)->first();
@@ -221,7 +223,7 @@ private function updateOrder()
private function handleBulkSubmit()
{
- $variables = parseEnvFormatToArray($this->variables);
+ $variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -241,7 +243,7 @@ private function handleBulkSubmit()
}
if ($this->showPreview) {
- $previewVariables = parseEnvFormatToArray($this->variablesPreview);
+ $previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
@@ -267,6 +269,7 @@ private function handleBulkSubmit()
private function handleSingleSubmit($data)
{
+ $data['key'] = ValidationPatterns::validatedEnvironmentVariableKey($data['key']);
$found = $this->resource->environment_variables()->where('key', $data['key'])->first();
if ($found) {
$this->dispatch('error', 'Environment variable already exists.');
@@ -334,6 +337,23 @@ private function deleteRemovedVariables($isPreview, $variables)
return $variablesToDelete->count();
}
+ private function normalizeEnvironmentVariables(array $variables): array
+ {
+ $normalizedVariables = [];
+
+ foreach ($variables as $key => $data) {
+ $normalizedKey = ValidationPatterns::validatedEnvironmentVariableKey((string) $key);
+
+ if (array_key_exists($normalizedKey, $normalizedVariables)) {
+ throw new \InvalidArgumentException("Duplicate environment variable key after normalization: {$normalizedKey}.");
+ }
+
+ $normalizedVariables[$normalizedKey] = $data;
+ }
+
+ return $normalizedVariables;
+ }
+
private function updateOrCreateVariables($isPreview, $variables)
{
$count = 0;
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 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 @@ public function refresh()
public function syncData(bool $toModel = false)
{
if ($toModel) {
+ $this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
+
if ($this->isSharedVariable) {
$this->validate([
- 'key' => 'required|string',
+ 'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'nullable',
'comment' => 'nullable|string|max:256',
'is_multiline' => 'required|boolean',
@@ -233,7 +248,7 @@ public function availableSharedVariables(): array
$result['team'] = $team->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view team variables
}
@@ -264,12 +279,12 @@ public function availableSharedVariables(): array
$result['environment'] = $environment->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view environment variables
}
}
}
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view project variables
}
}
@@ -279,7 +294,7 @@ public function availableSharedVariables(): array
$serverUuid = data_get($this->parameters, 'server_uuid');
if ($serverUuid) {
// If we have a specific server_uuid, show variables for that server
- $server = \App\Models\Server::where('team_id', $team->id)
+ $server = Server::where('team_id', $team->id)
->where('uuid', $serverUuid)
->first();
@@ -289,7 +304,7 @@ public function availableSharedVariables(): array
$result['server'] = $server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -297,7 +312,7 @@ public function availableSharedVariables(): array
// For application environment variables, try to use the application's destination server
$applicationUuid = data_get($this->parameters, 'application_uuid');
if ($applicationUuid) {
- $application = \App\Models\Application::whereRelation('environment.project.team', 'id', $team->id)
+ $application = Application::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $applicationUuid)
->with('destination.server')
->first();
@@ -308,7 +323,7 @@ public function availableSharedVariables(): array
$result['server'] = $application->destination->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
@@ -316,7 +331,7 @@ public function availableSharedVariables(): array
// For service environment variables, try to use the service's server
$serviceUuid = data_get($this->parameters, 'service_uuid');
if ($serviceUuid) {
- $service = \App\Models\Service::whereRelation('environment.project.team', 'id', $team->id)
+ $service = Service::whereRelation('environment.project.team', 'id', $team->id)
->where('uuid', $serviceUuid)
->with('server')
->first();
@@ -327,7 +342,7 @@ public function availableSharedVariables(): array
$result['server'] = $service->server->environment_variables()
->pluck('key')
->toArray();
- } catch (\Illuminate\Auth\Access\AuthorizationException $e) {
+ } catch (AuthorizationException $e) {
// User not authorized to view server variables
}
}
diff --git a/app/Livewire/Project/Shared/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/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 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$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 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
$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 37d5332f3..c275ec097 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -5,6 +5,7 @@
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
@@ -29,8 +30,10 @@ class ApiTokens extends Component
public $isApiEnabled;
+ #[Locked]
public bool $canUseRootPermissions = false;
+ #[Locked]
public bool $canUseWritePermissions = false;
public function render()
@@ -54,7 +57,7 @@ private function getTokens()
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']);
@@ -62,7 +65,7 @@ public function updatedPermissions($permissionToUpdate)
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']);
@@ -72,7 +75,7 @@ public function updatedPermissions($permissionToUpdate)
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'];
@@ -90,11 +93,11 @@ public function addNewToken()
$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.');
}
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 @@ public function add($name)
} 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 @@ public function delete(string $fileName)
// 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 @@ public function addDynamicConfiguration()
'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..06aebd8f8 100644
--- a/app/Livewire/Server/Sentinel.php
+++ b/app/Livewire/Server/Sentinel.php
@@ -93,7 +93,9 @@ public function handleSentinelRestarted($event)
{
if ($event['serverUuid'] === $this->server->uuid) {
$this->server->refresh();
- $this->syncData();
+ // Only refresh display-only state; never re-sync text-input properties
+ // (would clobber any unsaved typing — see coolify#6062 / #6354 / #9695).
+ $this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->dispatch('success', 'Sentinel has been restarted successfully.');
}
}
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 84cb65ee6..d7339dcdb 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -32,6 +32,8 @@ class Show extends Component
public string $port;
+ public int $connectionTimeout;
+
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@@ -110,6 +112,7 @@ protected function rules(): array
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
+ 'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@@ -138,6 +141,10 @@ protected function messages(): array
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
+ 'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
+ 'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
+ 'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
+ 'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@@ -210,6 +217,7 @@ public function syncData(bool $toModel = false)
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
+ $this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
+ $this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@@ -268,7 +277,9 @@ public function handleSentinelRestarted($event)
// 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 @@ public function checkHetznerServerStatus(bool $manual = false)
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@@ -448,12 +459,15 @@ public function handleServerValidated($event = null)
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 @@ public function startHetznerServer()
return;
}
- $hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
+ $hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
diff --git a/app/Livewire/Settings/Advanced.php b/app/Livewire/Settings/Advanced.php
index 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 @@ public function rules()
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
+ 'is_mcp_server_enabled' => 'boolean',
];
}
@@ -67,6 +71,7 @@ public function mount()
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
+ $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
}
public function submit()
@@ -150,6 +155,7 @@ public function instantSave()
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
+ $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
diff --git a/app/Livewire/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\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 @@ protected function rules(): array
'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 @@ private function syncData(bool $toModel = false): void
}
}
+ 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 @@ public function checkPermissions()
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 @@ public function mount()
// 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 @@ public function mount()
}
}
$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/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 @@ private function generateInviteLink(bool $sendEmail = false)
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 @@ private function generateInviteLink(bool $sendEmail = false)
'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 @@ public function makeAdmin()
|| 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 @@ public function makeOwner()
|| 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 @@ public function makeReadonly()
|| 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 @@ public function remove()
}
$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 85e94bfd6..a1d34600e 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -4,6 +4,9 @@
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 @@
'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.'],
@@ -720,14 +723,14 @@ public function dockerfileLocation(): Attribute
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, '/');
}
);
}
@@ -886,8 +889,8 @@ public function status(): Attribute
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),
);
}
@@ -960,7 +963,7 @@ public function runtime_environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables()
@@ -970,6 +973,13 @@ public function nixpacks_environment_variables()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', false)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
@@ -988,7 +998,7 @@ public function runtime_environment_variables_preview()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
- ->where('key', 'not like', 'NIXPACKS_%');
+ ->withoutBuildpackControlVariables();
}
public function nixpacks_environment_variables_preview()
@@ -998,6 +1008,13 @@ public function nixpacks_environment_variables_preview()
->where('key', 'like', 'NIXPACKS_%');
}
+ public function railpack_environment_variables_preview()
+ {
+ return $this->morphMany(EnvironmentVariable::class, 'resourceable')
+ ->where('is_preview', true)
+ ->where('key', 'like', 'RAILPACK_%');
+ }
+
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
@@ -1045,7 +1062,7 @@ public function isDeploymentInprogress()
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()
@@ -1117,7 +1134,7 @@ public function deploymentType()
public function could_set_build_commands(): bool
{
- if ($this->build_pack === 'nixpacks') {
+ if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') {
return true;
}
@@ -1156,33 +1173,95 @@ public function isLogDrainEnabled()
}
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()
@@ -1200,15 +1279,19 @@ public function dirOnServer()
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.
@@ -1219,9 +1302,9 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
// 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) {
@@ -1232,10 +1315,10 @@ public function setGitImportSettings(string $deployment_uuid, string $git_clone_
}
// Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
- $git_clone_command = "{$git_clone_command} git submodule sync && {$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;
@@ -1476,6 +1559,11 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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);
@@ -1488,7 +1576,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$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));
@@ -1499,7 +1587,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
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"));
@@ -1524,12 +1612,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$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([
@@ -1552,7 +1641,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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) {
@@ -1595,12 +1684,13 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
}
$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([
@@ -1623,7 +1713,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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) {
@@ -1631,14 +1721,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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);
}
}
@@ -1659,6 +1749,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
$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') {
@@ -1668,7 +1759,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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) {
@@ -1676,14 +1767,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} 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);
}
}
@@ -1932,13 +2023,15 @@ public function fqdns(): Attribute
);
}
- 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;
@@ -2253,7 +2346,7 @@ public function setConfig($config)
'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 @@
'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/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 @@
)]
class EnvironmentVariable extends BaseModel
{
+ public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
+
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
@@ -74,11 +78,11 @@ class EnvironmentVariable extends BaseModel
'resourceable_id' => 'integer',
];
- protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_nixpacks', 'is_coolify'];
+ protected $appends = ['real_value', 'is_shared', 'is_really_required', 'is_buildpack_control', 'is_coolify'];
protected static function booted()
{
- static::created(function (EnvironmentVariable $environment_variable) {
+ static::created(function (ModelsEnvironmentVariable $environment_variable) {
if ($environment_variable->resourceable_type === Application::class && ! $environment_variable->is_preview) {
$found = ModelsEnvironmentVariable::where('key', $environment_variable->key)
->where('resourceable_type', Application::class)
@@ -109,7 +113,7 @@ protected static function booted()
]);
});
- static::saving(function (EnvironmentVariable $environmentVariable) {
+ static::saving(function (ModelsEnvironmentVariable $environmentVariable) {
$environmentVariable->updateIsShared();
});
}
@@ -119,6 +123,30 @@ public function service()
return $this->belongsTo(Service::class);
}
+ public function scopeWithoutBuildpackControlVariables(Builder $query): Builder
+ {
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ $query->where('key', 'not like', "{$prefix}%");
+ }
+
+ return $query;
+ }
+
+ public static function isBuildpackControlKey(?string $key): bool
+ {
+ if (blank($key)) {
+ return false;
+ }
+
+ foreach (self::BUILDPACK_CONTROL_VARIABLE_PREFIXES as $prefix) {
+ if (str($key)->startsWith($prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
protected function value(): Attribute
{
return Attribute::make(
@@ -188,16 +216,10 @@ protected function isReallyRequired(): Attribute
);
}
- protected function isNixpacks(): Attribute
+ protected function isBuildpackControl(): Attribute
{
return Attribute::make(
- get: function () {
- if (str($this->key)->startsWith('NIXPACKS_')) {
- return true;
- }
-
- return false;
- }
+ get: fn () => self::isBuildpackControlKey($this->key),
);
}
@@ -349,7 +371,9 @@ private function set_environment_variables(?string $environment_variable = null)
protected function key(): Attribute
{
return Attribute::make(
- set: fn (string $value) => str($value)->trim()->replace(' ', '_')->value,
+ set: fn (string $value) => ValidationPatterns::validatedEnvironmentVariableKey(
+ ValidationPatterns::normalizeEnvironmentVariableKey($value)
+ ),
);
}
diff --git a/app/Models/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 @@ public static function ownedByCurrentTeam()
});
}
- 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 @@
class LocalFileVolume extends BaseModel
{
+ public const MAX_CONTENT_SIZE = 5_242_880;
+
+ public const BINARY_PLACEHOLDER = '[binary file]';
+
+ public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
+
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
- public $appends = ['is_binary'];
+ public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
- get: function () {
- return $this->content === '[binary file]';
- }
+ get: fn () => $this->content === self::BINARY_PLACEHOLDER
+ );
+ }
+
+ protected function isTooLarge(): Attribute
+ {
+ return Attribute::make(
+ get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ $this->is_directory = false;
+ $this->save();
+
+ return;
+ }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
- $content = '[binary file]';
+ $content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
+ protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
+ {
+ $sizeOutput = instant_remote_process(
+ ["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
+ $server,
+ false,
+ );
+ $size = (int) trim((string) $sizeOutput);
+
+ return $size > self::MAX_CONTENT_SIZE;
+ }
+
public function deleteStorageOnServer()
{
$this->load(['service']);
@@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
- $content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
+ $this->content = self::TOO_LARGE_PLACEHOLDER;
+ } else {
+ $this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
+ }
$this->is_directory = false;
- $this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
diff --git a/app/Models/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 3f6ee51cc..190ee6e67 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -14,7 +14,12 @@ 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',
@@ -157,6 +162,10 @@ public function testConnection(bool $shouldSave = false)
'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();
@@ -164,11 +173,12 @@ public function testConnection(bool $shouldSave = false)
$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) {
@@ -183,11 +193,25 @@ public function testConnection(bool $shouldSave = false)
$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 @@ public function executions(): HasMany
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
- public function server()
+ public function server(): ?Server
{
if ($this->application) {
- if ($this->application->destination && $this->application->destination->server) {
- return $this->application->destination->server;
- }
- } elseif ($this->service) {
- if ($this->service->destination && $this->service->destination->server) {
- return $this->service->destination->server;
- }
- } elseif ($this->database) {
- if ($this->database->destination && $this->database->destination->server) {
- return $this->database->destination->server;
- }
+ return $this->application->destination?->server;
+ }
+
+ if ($this->service) {
+ return $this->service->destination?->server;
}
return null;
diff --git a/app/Models/Server.php b/app/Models/Server.php
index 06426f211..74e8ba5b0 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
- if ($isReachable === true) {
- $this->unreachable_count = 0;
- $this->save();
+ if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
- $this->increment('unreachable_count');
-
- if ($this->unreachable_count === 1) {
- $this->settings->is_reachable = true;
- $this->settings->save();
-
- return;
- }
-
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
- $failedChecks = 0;
- for ($i = 0; $i < 3; $i++) {
- $status = $this->serverStatus();
- sleep(5);
- if (! $status) {
- $failedChecks++;
- }
- }
-
- if ($failedChecks === 3 && ! $unreachableNotificationSent) {
- $this->sendUnreachableNotification();
- }
+ $this->sendUnreachableNotification();
}
}
diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php
index 30fc1e165..79f62f4b7 100644
--- a/app/Models/ServerSetting.php
+++ b/app/Models/ServerSetting.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@@ -49,6 +50,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
+ 'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@@ -97,6 +99,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
+ 'connection_timeout',
];
protected $casts = [
@@ -108,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
+ 'connection_timeout' => 'integer',
];
protected static function booted()
@@ -141,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
- public static function isValidSentinelToken(string $token): bool
+ public static function isValidSentinelToken(?string $token): bool
{
+ if ($token === null) {
+ return false;
+ }
+
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
- public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
+ /**
+ * Returns a valid sentinel token, regenerating it if the stored value is
+ * empty, undecryptable, or otherwise invalid. Throws only when regeneration
+ * still fails to produce a valid token.
+ */
+ public function ensureValidSentinelToken(): string
+ {
+ try {
+ $token = $this->sentinel_token;
+ } catch (DecryptException) {
+ $token = null;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ // Clear undecryptable raw value so Eloquent's dirty-check won't try to
+ // decrypt the bad original during save().
+ $attrs = $this->getAttributes();
+ $attrs['sentinel_token'] = null;
+ $this->setRawAttributes($attrs, true);
+
+ $this->generateSentinelToken(save: true, ignoreEvent: true);
+ $this->refresh();
+ $token = $this->sentinel_token;
+ }
+
+ if (! self::isValidSentinelToken($token)) {
+ throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
+ }
+
+ return $token;
+ }
+
+ public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
- $token = json_encode($data);
- $encrypted = encrypt($token);
- $this->sentinel_token = $encrypted;
+ $token = encrypt(json_encode($data));
+ $this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();
diff --git a/app/Models/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 @@ public function extraFields()
}
$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 @@
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 @@ protected function serverStatus(): Attribute
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/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 @@
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 @@ protected function serverStatus(): Attribute
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 @@
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 @@ protected function serverStatus(): Attribute
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 @@
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 @@ protected function serverStatus(): Attribute
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 @@
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 @@ protected function serverStatus(): Attribute
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 @@
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 @@ protected function serverStatus(): Attribute
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 @@
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 @@ public function deleteVolumes()
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 @@
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 @@ protected function serverStatus(): Attribute
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/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 @@ protected static function booted()
});
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 237f3836f..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 @@ protected static function boot()
$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 @@ protected static function boot()
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);
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
index 0caa3a3a9..c29f7fc41 100644
--- a/app/Providers/HorizonServiceProvider.php
+++ b/app/Providers/HorizonServiceProvider.php
@@ -3,9 +3,12 @@
namespace App\Providers;
use App\Contracts\CustomJobRepositoryInterface;
+use App\Exceptions\DeploymentException;
use App\Models\ApplicationDeploymentQueue;
use App\Models\User;
use App\Repositories\CustomJobRepository;
+use Illuminate\Queue\Events\JobFailed;
+use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Contracts\JobRepository;
@@ -48,6 +51,26 @@ public function boot(): void
]);
}
});
+
+ Event::listen(function (JobFailed $event) {
+ if (! isCloud()) {
+ return;
+ }
+
+ $exception = $event->exception;
+ if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) {
+ return;
+ }
+
+ try {
+ $uuid = $event->job->uuid();
+ if ($uuid) {
+ app(JobRepository::class)->deleteFailed($uuid);
+ }
+ } catch (\Throwable $e) {
+ // Best-effort scrub; never mask the original failure.
+ }
+ });
}
protected function gate(): void
diff --git a/app/Rules/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 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
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/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 58dbbe1ac..7e3974dd7 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -82,6 +82,12 @@ 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.
@@ -96,6 +102,159 @@ class ValidationPatterns
*/
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).
*
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 @@ public function deleteAllSessions(): void
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 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
- $token = $server->settings->sentinel_token;
- if (! ServerSetting::isValidSentinelToken($token)) {
- throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
+ $previousToken = null;
+ try {
+ $previousToken = $server->settings->sentinel_token;
+ } catch (DecryptException) {
+ // fall through to ensureValidSentinelToken which will regenerate
+ }
+ $token = $server->settings->ensureValidSentinelToken();
+ if ($token !== $previousToken) {
+ Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
}
$response = instant_remote_process(
@@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool
{
- return $this instanceof \App\Models\Server;
+ return $this instanceof Server;
}
- private function getMetricsServer(): \App\Models\Server
+ private function getMetricsServer(): Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}
diff --git a/app/Traits/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 @@ protected function isRetryableSshError(string $errorOutput): bool
'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 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 @@
'@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/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\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\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 881211513..08af8ee42 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -353,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) {
@@ -592,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])) {
@@ -1058,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;
@@ -1104,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
@@ -1143,7 +1139,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
- return $resource;
+ return null;
}
function generateTagDeployWebhook($tag_name)
{
@@ -1453,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);
@@ -3532,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';
}
}
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 @@
'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 @@
'protocol' => env('TERMINAL_PROTOCOL'),
'host' => env('TERMINAL_HOST'),
'port' => env('TERMINAL_PORT'),
+ 'command_timeout' => 0,
],
'pusher' => [
@@ -69,6 +71,10 @@
'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 @@
'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 @@
'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/logging.php b/config/logging.php
index 1dbb1135f..05cf8e13d 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
+
+ 'audit' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/audit.log'),
+ 'level' => env('LOG_AUDIT_LEVEL', 'info'),
+ 'days' => env('LOG_AUDIT_DAYS', 90),
+ 'replace_placeholders' => true,
+ ],
],
];
diff --git a/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_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 @@ public function run(): void
CaSslCertSeeder::class,
PersonalAccessTokenSeeder::class,
]);
+
+ if (in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
+ $this->call([
+ DevelopmentRailpackExamplesSeeder::class,
+ ]);
+ }
}
}
diff --git a/database/seeders/DevelopmentRailpackExamplesSeeder.php b/database/seeders/DevelopmentRailpackExamplesSeeder.php
new file mode 100644
index 000000000..78659b457
--- /dev/null
+++ b/database/seeders/DevelopmentRailpackExamplesSeeder.php
@@ -0,0 +1,513 @@
+isDevelopmentEnvironment()) {
+ $this->command?->warn('Skipping DevelopmentRailpackExamplesSeeder outside development mode.');
+
+ return;
+ }
+
+ $this->ensureDevelopmentPrerequisitesExist();
+ $destination = StandaloneDocker::query()->find(0);
+
+ if (! $destination) {
+ throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.');
+ }
+
+ $environment = $this->prepareEnvironment();
+
+ foreach (self::examples() as $example) {
+ $this->upsertApplication($environment, $destination, $example);
+ }
+ }
+
+ /**
+ * @return array>
+ */
+ public static function examples(): array
+ {
+ return [
+ [
+ 'uuid' => 'railpack-simple-webserver',
+ 'name' => 'Railpack Simple Webserver Example',
+ 'base_directory' => '/node/simple-webserver',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-expressjs',
+ 'name' => 'Railpack Express.js Example',
+ 'base_directory' => '/node/expressjs',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-fastify',
+ 'name' => 'Railpack Fastify Example',
+ 'base_directory' => '/node/fastify',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nestjs',
+ 'name' => 'Railpack NestJS Example',
+ 'base_directory' => '/node/nestjs',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start:prod',
+ ],
+ [
+ 'uuid' => 'railpack-adonisjs',
+ 'name' => 'Railpack AdonisJS Example',
+ 'base_directory' => '/node/adonisjs',
+ 'ports_exposes' => '3333',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-hono',
+ 'name' => 'Railpack Hono Example',
+ 'base_directory' => '/node/hono',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-koa',
+ 'name' => 'Railpack Koa Example',
+ 'base_directory' => '/node/koa',
+ 'ports_exposes' => '3000',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-ssr',
+ 'name' => 'Railpack Next.js SSR Example',
+ 'base_directory' => '/node/nextjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-ssr',
+ 'name' => 'Railpack NuxtJS SSR Example',
+ 'base_directory' => '/node/nuxtjs/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run preview -- --host 0.0.0.0 --port 3000',
+ ],
+ [
+ 'uuid' => 'railpack-astro-ssr',
+ 'name' => 'Railpack Astro SSR Example',
+ 'base_directory' => '/node/astro/ssr',
+ 'ports_exposes' => '4321',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-ssr',
+ 'name' => 'Railpack SvelteKit SSR Example',
+ 'base_directory' => '/node/sveltekit/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-ssr',
+ 'name' => 'Railpack TanStack Start SSR Example',
+ 'base_directory' => '/node/tanstack-start/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-angular-ssr',
+ 'name' => 'Railpack Angular SSR Example',
+ 'base_directory' => '/node/angular/ssr',
+ 'ports_exposes' => '4000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-vue-ssr',
+ 'name' => 'Railpack Vue SSR Example',
+ 'base_directory' => '/node/vue/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run start',
+ ],
+ [
+ 'uuid' => 'railpack-qwik-ssr',
+ 'name' => 'Railpack Qwik SSR Example',
+ 'base_directory' => '/node/qwik/ssr',
+ 'ports_exposes' => '3000',
+ 'build_command' => 'npm run build',
+ 'start_command' => 'npm run serve',
+ ],
+ [
+ 'uuid' => 'railpack-react-static',
+ 'name' => 'Railpack React Static Example',
+ 'base_directory' => '/node/react',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vite-static',
+ 'name' => 'Railpack Vite Static Example',
+ 'base_directory' => '/node/vite',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-eleventy-static',
+ 'name' => 'Railpack Eleventy Static Example',
+ 'base_directory' => '/node/eleventy',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/_site',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-gatsby-static',
+ 'name' => 'Railpack Gatsby Static Example',
+ 'base_directory' => '/node/gatsby',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/public',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nextjs-static',
+ 'name' => 'Railpack Next.js Static Example',
+ 'base_directory' => '/node/nextjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/out',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-nuxtjs-static',
+ 'name' => 'Railpack NuxtJS Static Example',
+ 'base_directory' => '/node/nuxtjs/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-astro-static',
+ 'name' => 'Railpack Astro Static Example',
+ 'base_directory' => '/node/astro/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ ],
+ [
+ 'uuid' => 'railpack-sveltekit-static',
+ 'name' => 'Railpack SvelteKit Static Example',
+ 'base_directory' => '/node/sveltekit/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/build',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-tanstack-start-static',
+ 'name' => 'Railpack TanStack Start Static Example',
+ 'base_directory' => '/node/tanstack-start/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/.output/public',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-angular-static',
+ 'name' => 'Railpack Angular Static Example',
+ 'base_directory' => '/node/angular/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist/static/browser',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-vue-static',
+ 'name' => 'Railpack Vue Static Example',
+ 'base_directory' => '/node/vue/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ [
+ 'uuid' => 'railpack-qwik-static',
+ 'name' => 'Railpack Qwik Static Example',
+ 'base_directory' => '/node/qwik/static',
+ 'ports_exposes' => '80',
+ 'build_command' => 'npm run build',
+ 'publish_directory' => '/dist',
+ 'is_static' => true,
+ 'is_spa' => true,
+ ],
+ // Multi-language examples (only available on v4.x branch).
+ [
+ 'uuid' => 'railpack-python-flask',
+ 'name' => 'Railpack Python Flask Example',
+ 'base_directory' => '/flask',
+ 'ports_exposes' => '5000',
+ 'git_branch' => 'v4.x',
+ 'start_command' => 'flask run --host=0.0.0.0 --port=5000',
+ ],
+ [
+ 'uuid' => 'railpack-go-gin',
+ 'name' => 'Railpack Go Gin Example',
+ 'base_directory' => '/go/gin',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rust',
+ 'name' => 'Railpack Rust Example',
+ 'base_directory' => '/rust',
+ 'ports_exposes' => '8000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel',
+ 'name' => 'Railpack Laravel Example',
+ 'base_directory' => '/laravel',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-pure',
+ 'name' => 'Railpack Laravel Pure Example',
+ 'base_directory' => '/laravel-pure',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-laravel-inertia',
+ 'name' => 'Railpack Laravel Inertia Example',
+ 'base_directory' => '/laravel-inertia',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-symfony',
+ 'name' => 'Railpack Symfony Example',
+ 'base_directory' => '/symfony',
+ 'ports_exposes' => '80',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-rails',
+ 'name' => 'Railpack Ruby on Rails Example',
+ 'base_directory' => '/rails-example',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-elixir-phoenix',
+ 'name' => 'Railpack Elixir Phoenix Example',
+ 'base_directory' => '/elixir-phoenix',
+ 'ports_exposes' => '4000',
+ 'git_branch' => 'v4.x',
+ ],
+ [
+ 'uuid' => 'railpack-bun',
+ 'name' => 'Railpack Bun Example',
+ 'base_directory' => '/bun',
+ 'ports_exposes' => '3000',
+ 'git_branch' => 'v4.x',
+ ],
+ ];
+ }
+
+ private function ensureDevelopmentPrerequisitesExist(): void
+ {
+ Team::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'name' => 'Root Team',
+ 'description' => 'The root team',
+ 'personal_team' => true,
+ ],
+ );
+
+ PrivateKey::query()->firstOrCreate(
+ ['id' => 1],
+ [
+ 'uuid' => 'ssh',
+ 'team_id' => 0,
+ 'name' => 'Testing Host Key',
+ 'description' => 'This is a test docker container',
+ 'private_key' => <<<'KEY'
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
+hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
+AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
+uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
+-----END OPENSSH PRIVATE KEY-----
+KEY,
+ ],
+ );
+
+ Server::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'localhost',
+ 'name' => 'localhost',
+ 'description' => 'This is a test docker container in development mode',
+ 'ip' => 'coolify-testing-host',
+ 'team_id' => 0,
+ 'private_key_id' => 1,
+ 'proxy' => [
+ 'type' => ProxyTypes::TRAEFIK->value,
+ 'status' => ProxyStatus::EXITED->value,
+ ],
+ ],
+ );
+
+ StandaloneDocker::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'docker',
+ 'name' => 'Standalone Docker 1',
+ 'network' => 'coolify',
+ 'server_id' => 0,
+ ],
+ );
+
+ $this->ensurePublicGithubSourceExists();
+ }
+
+ private function ensurePublicGithubSourceExists(): void
+ {
+ GithubApp::query()->firstOrCreate(
+ ['id' => 0],
+ [
+ 'uuid' => 'github-public',
+ 'name' => 'Public GitHub',
+ 'api_url' => 'https://api.github.com',
+ 'html_url' => 'https://github.com',
+ 'is_public' => true,
+ 'team_id' => 0,
+ ],
+ );
+ }
+
+ private function isDevelopmentEnvironment(): bool
+ {
+ return in_array(config('app.env'), ['local', 'development', 'dev'], true);
+ }
+
+ private function prepareEnvironment(): Environment
+ {
+ $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]);
+ $project->fill([
+ 'name' => 'Railpack Examples',
+ 'description' => 'Development-only Railpack examples from coollabsio/coolify-examples@next.',
+ 'team_id' => 0,
+ ]);
+ $project->save();
+
+ $environment = $project->environments()->first();
+
+ if (! $environment) {
+ $environment = $project->environments()->create([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ } else {
+ $environment->update([
+ 'name' => 'production',
+ 'uuid' => self::ENVIRONMENT_UUID,
+ ]);
+ }
+
+ return $environment;
+ }
+
+ /**
+ * @param array $example
+ */
+ private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void
+ {
+ $application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]);
+ $application->fill([
+ 'name' => $example['name'],
+ 'description' => $example['name'],
+ 'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io",
+ 'repository_project_id' => self::REPOSITORY_PROJECT_ID,
+ 'git_repository' => self::GIT_REPOSITORY,
+ 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH,
+ 'build_pack' => 'railpack',
+ 'ports_exposes' => $example['ports_exposes'],
+ 'base_directory' => $example['base_directory'],
+ 'publish_directory' => $example['publish_directory'] ?? null,
+ 'static_image' => 'nginx:alpine',
+ 'install_command' => $example['install_command'] ?? null,
+ 'build_command' => $example['build_command'] ?? null,
+ 'start_command' => $example['start_command'] ?? null,
+ 'environment_id' => $environment->id,
+ 'destination_id' => $destination->id,
+ 'destination_type' => StandaloneDocker::class,
+ 'source_id' => 0,
+ 'source_type' => GithubApp::class,
+ ]);
+ $application->save();
+
+ if ($application->trashed()) {
+ $application->restore();
+ }
+
+ $application->settings()->updateOrCreate(
+ ['application_id' => $application->id],
+ [
+ 'is_static' => $example['is_static'] ?? false,
+ 'is_spa' => $example['is_spa'] ?? false,
+ ],
+ );
+ }
+}
diff --git a/database/seeders/InstanceSettingsSeeder.php b/database/seeders/InstanceSettingsSeeder.php
index baa7abffc..930a7db8e 100644
--- a/database/seeders/InstanceSettingsSeeder.php
+++ b/database/seeders/InstanceSettingsSeeder.php
@@ -23,23 +23,25 @@ public function run(): void
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
]);
- try {
- $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
- $ipv4 = trim($ipv4);
- $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv4) && $ipv4) {
- $settings->update(['public_ipv4' => $ipv4]);
+ if (! isDev()) {
+ try {
+ $ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
+ $ipv4 = trim($ipv4);
+ $ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv4) && $ipv4) {
+ $settings->update(['public_ipv4' => $ipv4]);
+ }
+ $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
+ $ipv6 = trim($ipv6);
+ $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
+ $settings = instanceSettings();
+ if (is_null($settings->public_ipv6) && $ipv6) {
+ $settings->update(['public_ipv6' => $ipv6]);
+ }
+ } catch (\Throwable $e) {
+ echo "Error: {$e->getMessage()}\n";
}
- $ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
- $ipv6 = trim($ipv6);
- $ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
- $settings = instanceSettings();
- if (is_null($settings->public_ipv6) && $ipv6) {
- $settings->update(['public_ipv6' => $ipv6]);
- }
- } catch (\Throwable $e) {
- echo "Error: {$e->getMessage()}\n";
}
}
}
diff --git a/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 @@ public function run(): void
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 @@ public function run(): void
'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 @@ public function run(): void
]);
// 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 d83b30d80..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"
@@ -4381,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",
@@ -4393,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",
@@ -4771,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"
@@ -4951,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": {
@@ -4963,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": {
@@ -8650,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",
@@ -10545,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"
@@ -12499,6 +12466,7 @@
"description": "Build pack.",
"enum": [
"nixpacks",
+ "railpack",
"static",
"dockerfile",
"dockercompose"
@@ -12848,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"
},
@@ -13349,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 aab408098..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
@@ -2765,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'
@@ -2774,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)'
@@ -3039,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':
@@ -3160,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
@@ -3169,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
@@ -5484,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
@@ -6734,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':
@@ -7916,6 +7908,7 @@ components:
description: 'Build pack.'
enum:
- nixpacks
+ - railpack
- static
- dockerfile
- dockercompose
@@ -8185,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:
@@ -8538,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 20aa0e822..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.16.0",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
- "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 @@ @theme {
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 @@ @utility menu-item {
@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 {
}
@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-debug {
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}
+
+@media (min-width: 1024px) {
+ .sidebar-collapsed .menu-item {
+ justify-content: center;
+ 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/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)