Merge branch 'next' into update-homarr
This commit is contained in:
commit
1965516cae
78 changed files with 3976 additions and 1915 deletions
1666
.ai/design-system.md
1666
.ai/design-system.md
File diff suppressed because it is too large
Load diff
752
DESIGN.md
Normal file
752
DESIGN.md
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
---
|
||||
version: alpha
|
||||
name: Coolify
|
||||
description: Self-hosted PaaS. Dark-first utilitarian UI. Purple (light) / yellow (dark) accent swap. Sharp 2px radii. Inset box-shadow inputs with 4px dirty-bar indicator.
|
||||
colors:
|
||||
# Brand
|
||||
coollabs: "#6b16ed"
|
||||
coollabs-50: "#f5f0ff"
|
||||
coollabs-100: "#7317ff"
|
||||
coollabs-200: "#5a12c7"
|
||||
coollabs-300: "#4a0fa3"
|
||||
# Dark-mode accent (warning scale)
|
||||
warning: "#fcd452"
|
||||
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"
|
||||
# Dark surfaces
|
||||
base: "#101010"
|
||||
coolgray-100: "#181818"
|
||||
coolgray-200: "#202020"
|
||||
coolgray-300: "#242424"
|
||||
coolgray-400: "#282828"
|
||||
coolgray-500: "#323232"
|
||||
# Light surfaces (Tailwind neutrals)
|
||||
surface: "#ffffff"
|
||||
background: "#f9fafb"
|
||||
border: "#e5e5e5"
|
||||
text: "#000000"
|
||||
text-muted: "#737373"
|
||||
text-placeholder: "#d4d4d4"
|
||||
# Semantic
|
||||
success: "#22C55E"
|
||||
error: "#dc2626"
|
||||
primary: "{colors.coollabs}"
|
||||
typography:
|
||||
h1:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 1.875rem
|
||||
fontWeight: 700
|
||||
lineHeight: 1.2
|
||||
h2:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 1.25rem
|
||||
fontWeight: 700
|
||||
h3:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 1.125rem
|
||||
fontWeight: 700
|
||||
h4:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 1rem
|
||||
fontWeight: 700
|
||||
body-md:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 0.875rem
|
||||
fontWeight: 400
|
||||
lineHeight: 1.25rem
|
||||
label-md:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 0.875rem
|
||||
fontWeight: 500
|
||||
label-sm:
|
||||
fontFamily: "'Geist Sans', Inter, sans-serif"
|
||||
fontSize: 0.75rem
|
||||
fontWeight: 700
|
||||
lineHeight: 1rem
|
||||
mono:
|
||||
fontFamily: "'Geist Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace"
|
||||
fontSize: 0.875rem
|
||||
fontWeight: 400
|
||||
rounded:
|
||||
sm: 0.125rem # default — inputs, buttons, cards, modals
|
||||
md: 0.25rem # coolbox
|
||||
lg: 0.5rem # callouts
|
||||
full: 9999px # badges, pills
|
||||
spacing:
|
||||
xs: 0.25rem
|
||||
sm: 0.5rem
|
||||
md: 1rem
|
||||
lg: 1.5rem
|
||||
xl: 2rem
|
||||
section: 3rem
|
||||
sidebar-width: 14rem
|
||||
button-height: 2rem
|
||||
card-min-height: 4rem
|
||||
input-py: 0.375rem
|
||||
components:
|
||||
button:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.text}"
|
||||
rounded: "{rounded.sm}"
|
||||
height: "{spacing.button-height}"
|
||||
padding: 0 0.5rem
|
||||
button-dark:
|
||||
backgroundColor: "{colors.coolgray-100}"
|
||||
textColor: "#ffffff"
|
||||
button-hover:
|
||||
backgroundColor: "#f5f5f5"
|
||||
button-hover-dark:
|
||||
backgroundColor: "{colors.coolgray-200}"
|
||||
button-highlighted:
|
||||
backgroundColor: "{colors.coollabs-50}"
|
||||
textColor: "{colors.coollabs-200}"
|
||||
button-highlighted-hover:
|
||||
backgroundColor: "{colors.coollabs}"
|
||||
textColor: "#ffffff"
|
||||
button-error:
|
||||
backgroundColor: "#fef2f2"
|
||||
textColor: "#991b1b"
|
||||
button-error-hover:
|
||||
backgroundColor: "#fca5a5"
|
||||
textColor: "#ffffff"
|
||||
input:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.text}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 0.375rem 0.5rem
|
||||
input-dark:
|
||||
backgroundColor: "{colors.coolgray-100}"
|
||||
textColor: "#ffffff"
|
||||
textarea:
|
||||
typography: "{typography.mono}"
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.sm}"
|
||||
box:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.text}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: "{spacing.sm}"
|
||||
height: "{spacing.card-min-height}"
|
||||
box-dark:
|
||||
backgroundColor: "{colors.coolgray-100}"
|
||||
textColor: "#ffffff"
|
||||
box-hover:
|
||||
backgroundColor: "#f5f5f5"
|
||||
box-hover-dark:
|
||||
backgroundColor: "{colors.coollabs-100}"
|
||||
textColor: "#ffffff"
|
||||
coolbox:
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: "{spacing.sm}"
|
||||
height: "{spacing.card-min-height}"
|
||||
badge-success:
|
||||
backgroundColor: "{colors.success}"
|
||||
size: 0.75rem
|
||||
rounded: "{rounded.full}"
|
||||
badge-warning:
|
||||
backgroundColor: "{colors.warning}"
|
||||
size: 0.75rem
|
||||
rounded: "{rounded.full}"
|
||||
badge-error:
|
||||
backgroundColor: "{colors.error}"
|
||||
size: 0.75rem
|
||||
rounded: "{rounded.full}"
|
||||
deprecated-badge:
|
||||
backgroundColor: "rgba(252, 212, 82, 0.15)"
|
||||
textColor: "{colors.warning}"
|
||||
rounded: "{rounded.full}"
|
||||
padding: 0.125rem 0.5rem
|
||||
callout-warning:
|
||||
backgroundColor: "{colors.warning-50}"
|
||||
textColor: "{colors.warning-800}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: "{spacing.md}"
|
||||
callout-danger:
|
||||
backgroundColor: "#fef2f2"
|
||||
textColor: "#991b1b"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: "{spacing.md}"
|
||||
callout-info:
|
||||
backgroundColor: "#eff6ff"
|
||||
textColor: "#1e40af"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: "{spacing.md}"
|
||||
callout-success:
|
||||
backgroundColor: "#f0fdf4"
|
||||
textColor: "#166534"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: "{spacing.md}"
|
||||
dropdown:
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: "{spacing.xs}"
|
||||
dropdown-dark:
|
||||
backgroundColor: "{colors.coolgray-200}"
|
||||
dropdown-item-hover:
|
||||
backgroundColor: "#f5f5f5"
|
||||
dropdown-item-hover-dark:
|
||||
backgroundColor: "{colors.coollabs}"
|
||||
textColor: "#ffffff"
|
||||
menu-item-active:
|
||||
backgroundColor: "#e5e5e5"
|
||||
textColor: "{colors.text}"
|
||||
rounded: "{rounded.sm}"
|
||||
menu-item-active-dark:
|
||||
backgroundColor: "{colors.coolgray-200}"
|
||||
textColor: "{colors.warning}"
|
||||
tag:
|
||||
backgroundColor: "#f5f5f5"
|
||||
textColor: "{colors.text-muted}"
|
||||
padding: 0.25rem 0.5rem
|
||||
kbd:
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 0 0.5rem
|
||||
toast:
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: "{spacing.md}"
|
||||
width: 20rem
|
||||
modal-input:
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.sm}"
|
||||
modal-input-dark:
|
||||
backgroundColor: "{colors.base}"
|
||||
modal-confirmation:
|
||||
backgroundColor: "#f5f5f5"
|
||||
rounded: "{rounded.sm}"
|
||||
modal-confirmation-dark:
|
||||
backgroundColor: "{colors.base}"
|
||||
---
|
||||
|
||||
# Coolify Design System
|
||||
|
||||
## Overview
|
||||
|
||||
Coolify is a self-hosted PaaS (Heroku/Netlify/Vercel alternative) built with Laravel 12, Livewire 3, and Tailwind CSS v4. UI is **dark-first, dense, utilitarian** — operators want information density over whitespace.
|
||||
|
||||
Brand personality: precise, engineered, no-nonsense. No flourish. No gradients outside a single branded upsell. Flat surfaces differentiated by tonal depth, not shadow.
|
||||
|
||||
Two signature traits define the system:
|
||||
|
||||
1. **Purple/Yellow accent swap.** Light mode uses `coollabs` purple `#6b16ed`. Dark mode swaps to `warning` yellow `#fcd452` for focus rings, active nav items, helper icons, loading spinners, highlighted text, helper links. Never use purple as an accent in dark mode.
|
||||
2. **Inset box-shadow inputs with a 4px "dirty bar".** Inputs and selects have no border — they use `box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px <border>`. When the field is focused or has unsaved changes (`wire:dirty`), the left 4px becomes the accent color — a live visual indicator of modified state. This is the single most distinctive UI detail in Coolify.
|
||||
|
||||
Sharp geometry everywhere: 2px corner radius by default (`rounded-sm`). 8px only on callouts. Shadows used sparingly — one `shadow-sm` on boxes, one drop-shadow on toasts. The rest is flat tonal layers.
|
||||
|
||||
## Colors
|
||||
|
||||
Source of truth: `resources/css/app.css` `@theme` block (Tailwind v4).
|
||||
|
||||
### Palettes
|
||||
|
||||
- **Primary / Coollabs (`#6b16ed`)** — brand purple. Light-mode accent. Used for focus rings, active states, highlighted buttons, spinners, scrollbar thumb. Scale: `coollabs-50 #f5f0ff` (backgrounds), `coollabs #6b16ed` (base), `coollabs-100 #7317ff` (dark-mode button hover), `coollabs-200 #5a12c7` (light-mode text), `coollabs-300 #4a0fa3` (deepest).
|
||||
- **Warning (`#fcd452`)** — dark-mode accent + callout palette. Full yellow scale `warning-50` through `warning-900`. Swaps in for coollabs under `.dark`.
|
||||
- **Coolgray (dark surface ladder)** — five shades building dark-mode depth: `base #101010` (page) → `coolgray-100 #181818` (components) → `-200 #202020` (elevated / active nav) → `-300 #242424` (input borders, button borders) → `-400 #282828` (tooltips) → `-500 #323232` (subtle overlays).
|
||||
- **Semantic** — `success #22C55E` for running/healthy, `error #dc2626` for stopped/danger.
|
||||
- **Light surfaces** — `gray-50 #f9fafb` (page), `white` (components), `neutral-200 #e5e5e5` (borders), `neutral-500 #737373` (muted text), `neutral-300 #d4d4d4` (placeholders).
|
||||
|
||||
### Dark-mode heading rule (critical)
|
||||
|
||||
Body default text in dark mode is `neutral-400 #a3a3a3`. Headings and card titles MUST explicitly force `text-white` — otherwise they render near-invisible on `coolgray-100 #181818`. This is enforced globally: `h1–h4` all have `dark:text-white` in `app.css`.
|
||||
|
||||
### Default border override
|
||||
|
||||
Tailwind v4 defaults `border-color` to `currentcolor`. Coolify overrides it in `@layer base`:
|
||||
|
||||
```css
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
border-color: var(--color-coolgray-200, currentcolor);
|
||||
}
|
||||
```
|
||||
|
||||
So any `border` utility without an explicit color gets `coolgray-200 #202020` in dark mode.
|
||||
|
||||
## Typography
|
||||
|
||||
Fonts loaded in `resources/css/fonts.css` (all `woff2`, `font-display: swap`):
|
||||
|
||||
- **Geist Sans** — primary UI font. Variable weight `100 900`. Inter as fallback (static weights 100–900).
|
||||
- **Geist Mono** — monospace for code, logs, textareas. Variable weight `100 900`.
|
||||
|
||||
Applied via `@theme`:
|
||||
|
||||
```css
|
||||
--font-sans: 'Geist Sans', Inter, sans-serif;
|
||||
--font-mono: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
--font-logs: 'Geist Mono', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
```
|
||||
|
||||
### Heading hierarchy (Tailwind utilities)
|
||||
|
||||
| Element | Utility |
|
||||
|---|---|
|
||||
| `h1` | `text-3xl font-bold dark:text-white` |
|
||||
| `h2` | `text-xl font-bold dark:text-white` |
|
||||
| `h3` | `text-lg font-bold dark:text-white` |
|
||||
| `h4` | `text-base font-bold dark:text-white` |
|
||||
|
||||
### Body
|
||||
|
||||
| Context | Utility |
|
||||
|---|---|
|
||||
| Body default | `text-sm font-sans antialiased` |
|
||||
| Label | `text-sm font-medium` |
|
||||
| Badge / status text | `text-xs font-bold` |
|
||||
| Box description | `text-xs font-bold text-neutral-500` |
|
||||
| Caption / kbd | `text-xs` |
|
||||
|
||||
## Layout
|
||||
|
||||
Fixed left sidebar layout on desktop. Mobile collapses to a sticky top bar with hamburger menu overlay.
|
||||
|
||||
### Structure
|
||||
|
||||
- **Sidebar** — fixed, `w-56` (14rem / 224px), `hidden lg:flex`. Inner `flex flex-col overflow-y-auto gap-y-5 scrollbar`. Nav `bg-white dark:bg-base border-r`.
|
||||
- **Main content** — `lg:pl-56` offset. Inner padding `p-4 sm:px-6 lg:px-8 lg:py-6`.
|
||||
- **Mobile top bar** — `sticky top-0 z-40 lg:hidden` with `bg-white/95 dark:bg-base/95 backdrop-blur-sm`.
|
||||
|
||||
### Spacing scale
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `p-2` | 0.5rem | Component internal padding |
|
||||
| `p-4` | 1rem | Callout padding |
|
||||
| `py-1.5` | 0.375rem | Input vertical padding |
|
||||
| `h-8` | 2rem | Button height |
|
||||
| `px-2` | 0.5rem | Button horizontal padding |
|
||||
| `gap-2` | 0.5rem | Button gap |
|
||||
| `px-2 py-1` | 0.25rem / 0.5rem | Menu item padding |
|
||||
| `gap-3` | 0.75rem | Menu item gap |
|
||||
| `mb-12` | 3rem | Section margin |
|
||||
| `min-h-[4rem]` | 4rem | Card min-height |
|
||||
|
||||
No grid system — flex layouts everywhere.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
**Flat + tonal.** Hierarchy comes from background color, not shadows.
|
||||
|
||||
### Dark tonal ladder
|
||||
|
||||
```
|
||||
#101010 (base) page background
|
||||
#181818 (coolgray-100) cards, inputs, components
|
||||
#202020 (coolgray-200) elevated surfaces, borders, nav active
|
||||
#242424 (coolgray-300) input borders, button borders
|
||||
#282828 (coolgray-400) tooltips, hover states
|
||||
#323232 (coolgray-500) subtle overlays
|
||||
```
|
||||
|
||||
### Light tonal ladder
|
||||
|
||||
```
|
||||
#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
|
||||
```
|
||||
|
||||
### Shadows (used sparingly)
|
||||
|
||||
- Boxes: `shadow-sm` (`0 1px 2px 0 rgba(0,0,0,0.05)`)
|
||||
- Toasts: `shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)]`
|
||||
- Slide-over: `shadow-lg`
|
||||
- Modal-input: `drop-shadow-sm`
|
||||
|
||||
### Input inset box-shadow system (distinctive)
|
||||
|
||||
Inputs and selects use `box-shadow` instead of `border` — this enables the 4px left dirty-bar indicator:
|
||||
|
||||
```css
|
||||
/* default */ box-shadow: inset 4px 0 0 transparent, inset 0 0 0 2px #e5e5e5;
|
||||
/* default dark */ inset 4px 0 0 transparent, inset 0 0 0 2px #242424;
|
||||
/* focus light */ inset 4px 0 0 #6b16ed, inset 0 0 0 2px #e5e5e5;
|
||||
/* focus dark */ inset 4px 0 0 #fcd452, inset 0 0 0 2px #242424;
|
||||
/* dirty (same as focus) — set via wire:dirty.class */
|
||||
/* disabled / readonly */ box-shadow: none;
|
||||
```
|
||||
|
||||
Variant `input-sticky` uses `1px` outer shadow instead of `2px`.
|
||||
|
||||
### Focus ring (buttons, links, checkboxes, non-input)
|
||||
|
||||
`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`
|
||||
|
||||
## Shapes
|
||||
|
||||
- **Default** — `rounded-sm` (2px). Everything: inputs, buttons, cards, modals, toasts, dropdowns.
|
||||
- **Coolbox** — `rounded` (4px). Alternate card style with ring-hover.
|
||||
- **Callouts** — `rounded-lg` (8px). Only exception to the sharp rule.
|
||||
- **Badges / deprecated badge / pills / avatars** — `rounded-full`.
|
||||
|
||||
Never mix radii within the same view.
|
||||
|
||||
## Components
|
||||
|
||||
All component classes live in `resources/css/utilities.css` as `@utility` blocks, consumed by Blade components under `resources/views/components/`.
|
||||
|
||||
### Forms
|
||||
|
||||
#### Button
|
||||
|
||||
Utility `.button` (`resources/css/utilities.css`):
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Attribute variants (in `app.css`):
|
||||
|
||||
- `button[isHighlighted]` → `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`
|
||||
- `button[isError]` → `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`
|
||||
|
||||
Loading: `<x-loading-on-button>` — inline `w-4 h-4 dark:text-warning animate-spin` SVG.
|
||||
|
||||
#### Input
|
||||
|
||||
Utility chain `.input-select` → `.input`:
|
||||
|
||||
```
|
||||
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 placeholder:text-neutral-300 dark:placeholder:text-neutral-700 read-only:text-neutral-500 read-only:bg-neutral-200 dark:read-only:text-neutral-500 dark:read-only:bg-coolgray-100/40 focus-visible:outline-none
|
||||
```
|
||||
|
||||
Plus the inset box-shadow system (see Elevation). Password variant: `.input[type="password"]` gets `pr-[2.4rem]` for the eye icon.
|
||||
|
||||
**Dirty indicator.** Livewire sets the focus-colored shadow via `wire:dirty.class`:
|
||||
|
||||
```blade
|
||||
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]"
|
||||
```
|
||||
|
||||
Variant `.input-sticky` — same shape, `1px` outer shadow (thinner border).
|
||||
|
||||
#### Select
|
||||
|
||||
Extends `.input-select` + custom SVG dropdown arrow:
|
||||
|
||||
```css
|
||||
background-image: url("data:image/svg+xml,...stroke='%23000000'...");
|
||||
padding-right: 2.5rem;
|
||||
```
|
||||
|
||||
Dark mode swaps the SVG stroke to `%23ffffff`.
|
||||
|
||||
#### Checkbox
|
||||
|
||||
Input class:
|
||||
```
|
||||
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:
|
||||
```
|
||||
form-control flex max-w-full flex-row items-center gap-4 py-1 pr-2 dark:hover:bg-coolgray-100 cursor-pointer
|
||||
```
|
||||
|
||||
#### Textarea
|
||||
|
||||
Uses the same `input` utility + `font-mono` + dirty-bar via `wire:dirty.class` (identical to input). Optional `@keydown.tab=handleKeydown` inserts 2 spaces on Tab.
|
||||
|
||||
#### Copy-Button
|
||||
|
||||
`resources/views/components/forms/copy-button.blade.php` — readonly `.input` with an absolute-positioned copy icon right-side. Copied state shows a green check (`text-green-500`) for 1 second. Only renders in secure contexts (`window.isSecureContext`).
|
||||
|
||||
### Containers
|
||||
|
||||
#### Box
|
||||
|
||||
Utility `.box`:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
**Critical child text rule.** On dark hover, background becomes purple `#7317ff` — description text `#737373` disappears. Utilities `.box-title` and `.box-description` include `dark:group-hover:text-white group-hover:text-black` to flip text contrast.
|
||||
|
||||
Variants: `.box-boarding`, `.box-without-bg`, `.box-without-bg-without-border`.
|
||||
|
||||
#### Coolbox
|
||||
|
||||
Utility `.coolbox`:
|
||||
```
|
||||
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]
|
||||
```
|
||||
|
||||
Distinguished by `rounded` (4px, not 2px) and **ring-hover** instead of background change.
|
||||
|
||||
### Status & Badges
|
||||
|
||||
#### Badge base
|
||||
|
||||
```
|
||||
inline-block w-3 h-3 text-xs font-bold rounded-full leading-none border border-neutral-200 dark:border-black
|
||||
```
|
||||
|
||||
Fill utilities: `.badge-success` (`bg-success`), `.badge-warning` (`bg-warning`), `.badge-error` (`bg-error`). Dashboard variant `.badge-dashboard` is `absolute top-1 right-1 w-2.5 h-2.5`.
|
||||
|
||||
#### Status indicator pattern
|
||||
|
||||
Badge + label side-by-side. Components in `resources/views/components/status/`:
|
||||
|
||||
| Component | Badge | Text color | Loading? |
|
||||
|---|---|---|---|
|
||||
| `status/running` | `badge-success` | `text-success` (`#22C55E`) | Swaps to `badge-warning` while checking proxy |
|
||||
| `status/degraded` | `badge-warning` | `dark:text-warning` (`#fcd452`) | `<x-loading>` + `wire:loading.delay.longer` |
|
||||
| `status/restarting` | `badge-warning` | `dark:text-warning` | `<x-loading>` |
|
||||
| `status/stopped` | `badge-error` | `text-error` (`#dc2626`) | `<x-loading>` |
|
||||
|
||||
Layout: `<div class="flex items-center">` → badge → `<div class="pl-2 pr-1 text-xs font-bold {color}">{label}</div>` → optional `({health})` in same color.
|
||||
|
||||
#### Deprecated Badge
|
||||
|
||||
`resources/views/components/deprecated-badge.blade.php`:
|
||||
```
|
||||
px-2 py-0.5 text-xs font-medium leading-normal rounded-full bg-warning/15 text-warning border border-warning/30
|
||||
```
|
||||
|
||||
#### Tag
|
||||
|
||||
Utility `.tag`:
|
||||
```
|
||||
px-2 py-1 cursor-pointer box-description dark:bg-coolgray-100 dark:hover:bg-coolgray-300 bg-neutral-100 hover:bg-neutral-200
|
||||
```
|
||||
|
||||
### Overlays
|
||||
|
||||
#### Callout
|
||||
|
||||
Four types (`warning`, `danger`, `info`, `success`). Base: `relative p-4 border rounded-lg`.
|
||||
|
||||
| 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` |
|
||||
|
||||
Icon colors (600 light / 400 dark) match type.
|
||||
|
||||
#### Modal (input variant)
|
||||
|
||||
`resources/views/components/modal.blade.php`:
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
Backdrop: `bg-black/20 backdrop-blur-xs`. Close button: `w-8 h-8 rounded-full hover:bg-neutral-100 dark:hover:bg-coolgray-300` top-right, 24px `stroke-width=1.5` X icon.
|
||||
|
||||
#### Modal Confirmation
|
||||
|
||||
`resources/views/components/modal-confirmation.blade.php` — destructive-action 2-or-3-step wizard (checkboxes → confirm text → password):
|
||||
```
|
||||
relative w-full border rounded-none sm:rounded-sm min-w-full lg:min-w-[36rem] max-w-full sm:max-w-[48rem] h-screen sm:h-auto max-h-screen sm:max-h-[calc(100vh-2rem)] bg-neutral-100 border-neutral-400 dark:bg-base dark:border-coolgray-300 flex flex-col
|
||||
```
|
||||
|
||||
Uses `<x-callout type="danger">` for warning. Password step hidden for OAuth users.
|
||||
|
||||
#### Confirm Modal
|
||||
|
||||
`resources/views/components/confirm-modal.blade.php` — Livewire-bound simpler confirm dialog.
|
||||
|
||||
#### Popup / Popup-Small
|
||||
|
||||
Fixed bottom-right notification card with title / description / action button. `bg-white dark:bg-coolgray-100 border dark:border-coolgray-300 shadow-lg sm:rounded-sm`. Popup is responsive max-w-4xl, Popup-Small is `max-w-[46rem]`.
|
||||
|
||||
#### Slide-Over
|
||||
|
||||
`resources/views/components/slide-over.blade.php`:
|
||||
|
||||
Outer: `fixed inset-y-0 right-0 flex max-w-full pl-10`
|
||||
|
||||
Panel: `max-w-xl w-screen flex flex-col h-full py-6 overflow-hidden border-l shadow-lg bg-neutral-50 dark:bg-base dark:border-neutral-800 border-neutral-200`
|
||||
|
||||
#### Toast
|
||||
|
||||
`resources/views/components/toast.blade.php` — Alpine-powered stacked toast system.
|
||||
|
||||
- Container: `fixed ... sm:max-w-xs z-9999`, positioned via `position` param (`top-right` / `top-left` / `top-center` / `bottom-right` / `bottom-left` / `bottom-center`).
|
||||
- Toast shell: `relative flex flex-col items-start shadow-[0_5px_15px_-3px_rgb(0_0_0_/_0.08)] w-full dark:bg-coolgray-100 bg-white dark:border dark:border-coolgray-200 rounded-sm sm:max-w-xs`.
|
||||
- Stacks up to 4 (oldest gets scale 82% then burns).
|
||||
- Auto-dismiss after 4 s. Hover on container pauses dismissal and expands stack.
|
||||
- HTML payload sanitized via `window.sanitizeHTML` (XSS guard).
|
||||
- Per-toast copy-to-clipboard + close buttons.
|
||||
|
||||
Icon colors:
|
||||
|
||||
| Type | Class |
|
||||
|---|---|
|
||||
| success | `text-green-500` |
|
||||
| info | `text-blue-500` |
|
||||
| warning | `text-orange-400` |
|
||||
| danger | `text-red-500` |
|
||||
| default | `text-gray-800` |
|
||||
|
||||
#### Helper / Tooltip
|
||||
|
||||
`resources/views/components/helper.blade.php`. Icon utility `.info-helper`:
|
||||
```
|
||||
cursor-pointer text-coollabs dark:text-warning
|
||||
```
|
||||
|
||||
Popup utility `.info-helper-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
|
||||
```
|
||||
|
||||
Shown on parent `.group:hover`. Supports rich HTML (links colored `text-coollabs dark:text-warning underline`).
|
||||
|
||||
### Navigation
|
||||
|
||||
#### Sidebar / Navbar
|
||||
|
||||
Component: `resources/views/components/navbar.blade.php`.
|
||||
|
||||
Root nav: `flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base`
|
||||
|
||||
Menu list: `flex flex-col flex-1 gap-y-7` → inner `flex flex-col h-full space-y-1.5`.
|
||||
|
||||
Utility `.menu-item`:
|
||||
```
|
||||
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-active`:
|
||||
```
|
||||
text-black rounded-sm dark:bg-coolgray-200 dark:text-warning bg-neutral-200 overflow-hidden
|
||||
```
|
||||
|
||||
Icon `.menu-item-icon`: `flex-shrink-0 w-6 h-6 dark:hover:text-white`. Sub-items use `gap-2` + `w-4 h-4` icons.
|
||||
|
||||
#### Breadcrumbs
|
||||
|
||||
`resources/views/components/resources/breadcrumbs.blade.php` — project → environment → resource trail. Desktop: `<ol class="hidden flex-wrap items-center gap-y-1 md:flex">`. Each link `text-xs lg:text-sm hover:text-warning`. Chevron buttons `text-warning`. Dropdowns `absolute ... bg-white dark:bg-coolgray-100 rounded-md shadow-lg border`. Active item `dark:text-warning font-semibold`.
|
||||
|
||||
#### External-Link
|
||||
|
||||
Mini icon — `inline-flex w-3 h-3 dark:text-neutral-400 text-black` with arrow-out-of-box SVG. Appended to external anchors.
|
||||
|
||||
#### Internal-Link
|
||||
|
||||
Arrow SVG — `inline-flex w-4 h-4 text-black dark:text-white`. Used in CTA links ("go to deployment" etc).
|
||||
|
||||
#### Banner
|
||||
|
||||
`resources/views/components/banner.blade.php` — dismissible top bar:
|
||||
```
|
||||
relative z-999 w-full py-2 mx-auto duration-100 ease-out shadow-xs bg-coolgray-100 sm:py-0 sm:h-14
|
||||
```
|
||||
|
||||
Close button: `w-6 h-6 rounded-full hover:bg-coolgray-500 text-neutral-200`. Reveals via Alpine `x-transition` after 100ms delay.
|
||||
|
||||
### Feedback
|
||||
|
||||
#### Loading Spinner
|
||||
|
||||
`resources/views/components/loading.blade.php` — inline flex with optional text + spinning SVG:
|
||||
```
|
||||
w-4 h-4 mx-1 ml-3 text-coollabs dark:text-warning animate-spin
|
||||
```
|
||||
|
||||
SVG has two paths at `opacity-25` (track) + `opacity-75` (arc).
|
||||
|
||||
Utility `.loading`: `w-4 dark:text-warning text-coollabs`.
|
||||
|
||||
#### Loading-On-Button
|
||||
|
||||
`resources/views/components/loading-on-button.blade.php` — same SVG but **no light-mode color** (`w-4 h-4 mx-1 ml-3 dark:text-warning animate-spin`), meant to inherit button text color.
|
||||
|
||||
#### Page-Loading
|
||||
|
||||
Full-page loader overlay (variant of `loading` component, fills viewport).
|
||||
|
||||
### Text
|
||||
|
||||
#### Highlighted text
|
||||
|
||||
`resources/views/components/highlighted.blade.php` / utility `.text-helper`:
|
||||
```
|
||||
inline-block font-bold text-coollabs dark:text-warning
|
||||
```
|
||||
|
||||
Also used for required-field asterisks via `<x-highlighted text="*" />`.
|
||||
|
||||
#### Kbd
|
||||
|
||||
Utility `.kbd-custom`:
|
||||
```
|
||||
px-2 text-xs rounded-sm border border-dashed border-neutral-700 dark:text-warning
|
||||
```
|
||||
|
||||
### Chrome
|
||||
|
||||
#### Scrollbar
|
||||
|
||||
Utility `.scrollbar` (uses `tailwind-scrollbar` plugin):
|
||||
```
|
||||
scrollbar-thumb-coollabs-100 scrollbar-track-neutral-200 dark:scrollbar-track-coolgray-200 scrollbar-thin
|
||||
```
|
||||
|
||||
Applied globally to `<body>` in `app.css`.
|
||||
|
||||
#### Table
|
||||
|
||||
Styled via base element rules in `app.css` (not a reusable component):
|
||||
|
||||
```css
|
||||
table { @apply min-w-full divide-y dark:divide-coolgray-200 divide-neutral-300; }
|
||||
thead { @apply uppercase; }
|
||||
tbody { @apply divide-y dark:divide-coolgray-200 divide-neutral-300; }
|
||||
tr { @apply text-black dark:text-neutral-400 dark:hover:bg-coolgray-300 hover:bg-neutral-100; }
|
||||
tr th { @apply px-3 py-3.5 text-left text-black dark:text-white; }
|
||||
tr th:first-child { @apply py-3.5 pr-3 pl-4 sm:pl-6; }
|
||||
tr td { @apply px-3 py-4 whitespace-nowrap; }
|
||||
tr td:first-child { @apply pr-3 pl-4 font-bold sm:pl-6; }
|
||||
```
|
||||
|
||||
#### Dropdown
|
||||
|
||||
`resources/views/components/dropdown.blade.php`. Container:
|
||||
```
|
||||
border border-neutral-300 bg-white p-1 shadow-sm dark:border-coolgray-300 dark:bg-coolgray-200
|
||||
```
|
||||
|
||||
Utility `.dropdown-item`:
|
||||
```
|
||||
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 data-disabled:pointer-events-none data-disabled:opacity-50 focus-visible:bg-neutral-100 dark:focus-visible:bg-coollabs
|
||||
```
|
||||
|
||||
Touch variant adds `min-h-10 px-3 py-2 text-sm`.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
- **Do** force `dark:text-white` on h1–h4 and card titles. Default body text `#a3a3a3` is unreadable on `coolgray-100`.
|
||||
- **Do** swap the accent: `coollabs` in light, `warning` in dark. For focus rings, active nav, helpers, spinners, highlighted text, scrollbar thumb, helper links.
|
||||
- **Do** use the inset box-shadow system on inputs, selects, and textareas — not a border. It enables the 4px left dirty-bar.
|
||||
- **Do** wire the dirty indicator via `wire:dirty.class` so Livewire flips the bar color on modified state.
|
||||
- **Do** flip `.box-title` and `.box-description` to the contrast color on hover. On dark hover the card goes purple `#7317ff`; `text-neutral-500` description becomes invisible.
|
||||
- **Do** maintain WCAG AA contrast (4.5:1 for normal text).
|
||||
- **Do** sanitize HTML passed into toasts via `window.sanitizeHTML`.
|
||||
- **Do** use `<x-loading>` for in-button spinners and as `wire:loading.delay.longer` indicators in status components.
|
||||
- **Don't** use purple `coollabs` as the dark-mode accent. Always use yellow `warning` in dark.
|
||||
- **Don't** mix corner radii — 2px everywhere except callouts (8px) and pills (full).
|
||||
- **Don't** use shadows for elevation in dark mode. Use tonal layers from the coolgray ladder.
|
||||
- **Don't** set `border` utilities without expecting `coolgray-200` in dark (default override in base layer).
|
||||
- **Don't** add gradients. The one exception is the `.bg-coollabs-gradient` upsell strip.
|
||||
- **Don't** use more than two font weights on a single screen (typically 400 body + 700 bold).
|
||||
|
||||
---
|
||||
|
||||
Source files:
|
||||
- Theme tokens: `resources/css/app.css` (`@theme` block)
|
||||
- Fonts: `resources/css/fonts.css`
|
||||
- Component utilities: `resources/css/utilities.css`
|
||||
- Blade components: `resources/views/components/**/*.blade.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';
|
||||
|
|
|
|||
|
|
@ -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<class-string<\Throwable>, \Psr\Log\LogLevel::*>
|
||||
* @var array<class-string<Throwable>, LogLevel::*>
|
||||
*/
|
||||
protected $levels = [
|
||||
//
|
||||
|
|
@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
|
|||
/**
|
||||
* A list of the exception types that are not reported.
|
||||
*
|
||||
* @var array<int, class-string<\Throwable>>
|
||||
* @var array<int, class-string<Throwable>>
|
||||
*/
|
||||
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();
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
|
|||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$muxSocket = $sshConfig['muxFilename'];
|
||||
$connectionTimeout = config('constants.ssh.connection_timeout');
|
||||
$connectionTimeout = self::getConnectionTimeout($server);
|
||||
$serverInterval = config('constants.ssh.server_interval');
|
||||
$muxPersistTime = config('constants.ssh.mux_persist_time');
|
||||
|
||||
|
|
@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
|
|||
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
|
||||
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
if ($server->isIpv6()) {
|
||||
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
|
||||
} else {
|
||||
|
|
@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
|
|||
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
|
||||
}
|
||||
|
||||
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
|
||||
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
|
||||
|
||||
$delimiter = Hash::make($command);
|
||||
$delimiter = base64_encode($delimiter);
|
||||
|
|
@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
|
|||
}
|
||||
}
|
||||
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
return is_numeric($timeout) && (int) $timeout > 0
|
||||
? (int) $timeout
|
||||
: (int) config('constants.ssh.connection_timeout');
|
||||
}
|
||||
|
||||
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
|
||||
{
|
||||
$options = "-i {$sshKeyLocation} "
|
||||
|
|
|
|||
|
|
@ -1309,6 +1309,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'),
|
||||
|
|
@ -1539,6 +1548,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'),
|
||||
|
|
@ -1739,6 +1757,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,6 +1873,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'),
|
||||
|
|
@ -1956,6 +1992,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'),
|
||||
|
|
@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type)
|
|||
StartService::dispatch($service);
|
||||
}
|
||||
|
||||
auditLog('api.application.created', [
|
||||
'team_id' => $teamId,
|
||||
'service_uuid' => data_get($service, 'uuid'),
|
||||
'service_name' => data_get($service, 'name'),
|
||||
'application_type' => $type,
|
||||
'instant_deploy' => (bool) ($instantDeploy ?? false),
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($service, 'uuid'),
|
||||
'domains' => data_get($service, 'domains'),
|
||||
|
|
@ -2297,6 +2350,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.',
|
||||
]);
|
||||
|
|
@ -2796,6 +2855,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 +3114,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 +3155,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 +3389,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 +3534,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 +3567,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 +3666,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 +3788,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 +3885,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 +3982,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 +4357,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 +4544,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 +4626,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 +4707,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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
|
|||
StopDatabaseProxy::dispatch($database);
|
||||
}
|
||||
|
||||
auditLog('api.database.updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database updated.',
|
||||
]);
|
||||
|
|
@ -639,10 +647,10 @@ public function update_by_uuid(Request $request)
|
|||
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
|
||||
],
|
||||
),
|
||||
|
|
@ -703,10 +711,10 @@ public function create_backup(Request $request)
|
|||
'databases_to_backup' => 'string|nullable',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
|
||||
|
|
@ -826,6 +834,15 @@ public function create_backup(Request $request)
|
|||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
auditLog('api.database.backup_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'backup_uuid' => $backupConfig->uuid,
|
||||
'frequency' => $backupConfig->frequency,
|
||||
'save_s3' => (bool) $backupConfig->save_s3,
|
||||
'backup_now' => (bool) $request->backup_now,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'uuid' => $backupConfig->uuid,
|
||||
'message' => 'Backup configuration created successfully.',
|
||||
|
|
@ -878,10 +895,10 @@ public function create_backup(Request $request)
|
|||
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
|
||||
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
|
||||
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
|
||||
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
|
||||
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
|
||||
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
|
||||
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
|
||||
],
|
||||
),
|
||||
|
|
@ -933,10 +950,10 @@ public function update_backup(Request $request)
|
|||
'frequency' => 'string',
|
||||
'database_backup_retention_amount_locally' => 'integer|min:0',
|
||||
'database_backup_retention_days_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
|
||||
'database_backup_retention_amount_s3' => 'integer|min:0',
|
||||
'database_backup_retention_days_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'integer|min:0',
|
||||
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
|
||||
'timeout' => 'integer|min:60|max:36000',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
|
|
@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
|
|||
dispatch(new DatabaseBackupJob($backupConfig));
|
||||
}
|
||||
|
||||
auditLog('api.database.backup_updated', [
|
||||
'team_id' => $teamId,
|
||||
'backup_uuid' => $backupConfig->uuid,
|
||||
'database_id' => $backupConfig->database_id,
|
||||
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
|
||||
'backup_now' => (bool) $request->backup_now,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database backup configuration updated',
|
||||
]);
|
||||
|
|
@ -1779,6 +1804,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 +1873,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 +1942,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 +2008,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 +2104,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 +2150,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 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
|
|||
$payload['external_db_url'] = $database->external_db_url;
|
||||
}
|
||||
|
||||
auditLog('api.database.created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $type->value,
|
||||
'server_uuid' => $serverUuid,
|
||||
'is_public' => (bool) $database->is_public,
|
||||
'instant_deploy' => (bool) $instantDeploy,
|
||||
]);
|
||||
|
||||
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
|
||||
}
|
||||
|
||||
|
|
@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request)
|
|||
dockerCleanup: $request->boolean('docker_cleanup', true)
|
||||
);
|
||||
|
||||
auditLog('api.database.deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Database deletion request queued.',
|
||||
]);
|
||||
|
|
@ -2329,6 +2431,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 +2561,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 +2751,13 @@ public function action_deploy(Request $request)
|
|||
}
|
||||
StartDatabase::dispatch($database);
|
||||
|
||||
auditLog('api.database.started', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database starting request queued.',
|
||||
|
|
@ -2724,6 +2849,14 @@ public function action_stop(Request $request)
|
|||
$dockerCleanup = $request->boolean('docker_cleanup', true);
|
||||
StopDatabase::dispatch($database, $dockerCleanup);
|
||||
|
||||
auditLog('api.database.stopped', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
'docker_cleanup' => $dockerCleanup,
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database stopping request queued.',
|
||||
|
|
@ -2801,6 +2934,13 @@ public function action_restart(Request $request)
|
|||
|
||||
RestartDatabase::dispatch($database);
|
||||
|
||||
auditLog('api.database.restarted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'database_type' => $database->type(),
|
||||
]);
|
||||
|
||||
return response()->json(
|
||||
[
|
||||
'message' => 'Database restarting request queued.',
|
||||
|
|
@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
|
|||
}
|
||||
$env->save();
|
||||
|
||||
auditLog('api.database.env_updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
|
|
@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request)
|
|||
$updatedEnvs->push($this->removeSensitiveEnvData($env));
|
||||
}
|
||||
|
||||
auditLog('api.database.env_bulk_upserted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_count' => $updatedEnvs->count(),
|
||||
]);
|
||||
|
||||
return response()->json($updatedEnvs)->setStatusCode(201);
|
||||
}
|
||||
|
||||
|
|
@ -3266,6 +3419,13 @@ public function create_env(Request $request)
|
|||
'comment' => $request->comment ?? null,
|
||||
]);
|
||||
|
||||
auditLog('api.database.env_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $env->uuid,
|
||||
'env_key' => $env->key,
|
||||
]);
|
||||
|
||||
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
|
||||
}
|
||||
|
||||
|
|
@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
|
|||
return response()->json(['message' => 'Environment variable not found.'], 404);
|
||||
}
|
||||
|
||||
$envKey = $env->key;
|
||||
$envUuid = $env->uuid;
|
||||
$env->forceDelete();
|
||||
|
||||
auditLog('api.database.env_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'env_uuid' => $envUuid,
|
||||
'env_key' => $envKey,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Environment variable deleted.']);
|
||||
}
|
||||
|
||||
|
|
@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
|
|||
]);
|
||||
}
|
||||
|
||||
auditLog('api.database.storage_created', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path,
|
||||
]);
|
||||
|
||||
return response()->json($storage, 201);
|
||||
}
|
||||
|
||||
|
|
@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
|
|||
|
||||
$storage->save();
|
||||
|
||||
auditLog('api.database.storage_updated', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storage->uuid ?? null,
|
||||
'storage_id' => $storage->id,
|
||||
'storage_type' => $request->type,
|
||||
'mount_path' => $storage->mount_path ?? null,
|
||||
]);
|
||||
|
||||
return response()->json($storage);
|
||||
}
|
||||
|
||||
|
|
@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
|
|||
$storage->deleteStorageOnServer();
|
||||
}
|
||||
|
||||
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
|
||||
$storageMountPath = $storage->mount_path ?? null;
|
||||
$storage->delete();
|
||||
|
||||
auditLog('api.database.storage_deleted', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $database->uuid,
|
||||
'storage_uuid' => $storageUuid,
|
||||
'storage_type' => $storageType,
|
||||
'mount_path' => $storageMountPath,
|
||||
]);
|
||||
|
||||
return response()->json(['message' => 'Storage deleted.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,11 +141,15 @@ 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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class UploadController extends BaseController
|
|||
'archive.gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
'dmp',
|
||||
];
|
||||
|
||||
public function upload(Request $request)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
|
|
@ -12,6 +13,8 @@
|
|||
|
||||
class Bitbucket extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
|
|
@ -31,6 +34,16 @@ public function manual(Request $request)
|
|||
$branch = data_get($payload, 'push.changes.0.new.name');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$commit = data_get($payload, 'push.changes.0.new.target.hash');
|
||||
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
|
||||
// are evaluated only on the visible 5.
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(
|
||||
collect(data_get($payload, 'push.changes', []))
|
||||
->flatMap(fn ($change) => data_get($change, 'commits', []))
|
||||
->pluck('message')
|
||||
->filter()
|
||||
->values()
|
||||
->all()
|
||||
);
|
||||
|
||||
if (! $branch) {
|
||||
return response([
|
||||
|
|
@ -45,6 +58,8 @@ public function manual(Request $request)
|
|||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'pullrequest.id');
|
||||
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
|
||||
$pull_request_title = data_get($payload, 'pullrequest.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$commit = data_get($payload, 'pullrequest.source.commit.hash');
|
||||
}
|
||||
$applications = Application::where('git_repository', 'like', "%$full_name%");
|
||||
|
|
@ -58,6 +73,12 @@ public function manual(Request $request)
|
|||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -70,6 +91,12 @@ public function manual(Request $request)
|
|||
|
||||
$parts = explode('=', $x_bitbucket_token, 2);
|
||||
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
|
||||
auditLogWebhookFailure('bitbucket', 'malformed_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -81,6 +108,12 @@ public function manual(Request $request)
|
|||
$hash = $parts[1];
|
||||
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
|
||||
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
|
||||
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_bitbucket_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -101,6 +134,17 @@ public function manual(Request $request)
|
|||
}
|
||||
if ($x_bitbucket_event === 'repo:push') {
|
||||
if ($application->isDeployable()) {
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
|
|
@ -118,6 +162,15 @@ public function manual(Request $request)
|
|||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'bitbucket',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => $commit,
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
|
|
@ -134,6 +187,15 @@ public function manual(Request $request)
|
|||
}
|
||||
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
|
||||
if ($application->isPRDeployable()) {
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Webhook\Concerns;
|
||||
|
||||
trait DetectsSkipDeployCommits
|
||||
{
|
||||
/**
|
||||
* Returns true if there is at least one non-empty message and every message
|
||||
* contains [skip cd] or [skip ci] (case-insensitive).
|
||||
*
|
||||
* Accepts commit messages from a push payload. Null/empty entries are
|
||||
* filtered before evaluation.
|
||||
*
|
||||
* @param array<int, string|null> $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<int, string|null> $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;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
|
|
@ -13,6 +14,8 @@
|
|||
|
||||
class Gitea extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
|
|
@ -40,12 +43,15 @@ public function manual(Request $request)
|
|||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_gitea_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
}
|
||||
|
|
@ -68,6 +74,12 @@ public function manual(Request $request)
|
|||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitea_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -78,6 +90,12 @@ public function manual(Request $request)
|
|||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
auditLogWebhookFailure('gitea', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitea_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -100,6 +118,17 @@ public function manual(Request $request)
|
|||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
|
|
@ -117,6 +146,15 @@ public function manual(Request $request)
|
|||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'gitea',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
|
|
@ -149,6 +187,15 @@ public function manual(Request $request)
|
|||
if ($x_gitea_event === 'pull_request') {
|
||||
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
|
||||
if ($application->isPRDeployable()) {
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Jobs\ProcessGithubPullRequestWebhook;
|
||||
use App\Models\Application;
|
||||
|
|
@ -16,6 +17,8 @@
|
|||
|
||||
class Github extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
|
|
@ -43,12 +46,14 @@ public function manual(Request $request)
|
|||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$full_name = data_get($payload, 'repository.full_name');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$before_sha = data_get($payload, 'before');
|
||||
|
|
@ -82,6 +87,12 @@ public function manual(Request $request)
|
|||
foreach ($serverApplications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -92,6 +103,12 @@ public function manual(Request $request)
|
|||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
|
||||
auditLogWebhookFailure('github', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'mode' => 'manual',
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -114,6 +131,17 @@ public function manual(Request $request)
|
|||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
|
|
@ -131,6 +159,15 @@ public function manual(Request $request)
|
|||
'message' => $result['message'],
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'github',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
|
|
@ -180,6 +217,7 @@ public function manual(Request $request)
|
|||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
|
|
@ -224,6 +262,13 @@ public function normal(Request $request)
|
|||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (config('app.env') !== 'local') {
|
||||
if (! hash_equals($x_hub_signature_256, $hmac)) {
|
||||
auditLogWebhookFailure('github', 'invalid_signature', [
|
||||
'mode' => 'app',
|
||||
'github_app_id' => $github_app->id,
|
||||
'github_app_name' => $github_app->name,
|
||||
'installation_target_id' => $x_github_hook_installation_target_id,
|
||||
]);
|
||||
|
||||
return response('Invalid signature.');
|
||||
}
|
||||
}
|
||||
|
|
@ -246,12 +291,14 @@ public function normal(Request $request)
|
|||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_github_event === 'pull_request') {
|
||||
$action = data_get($payload, 'action');
|
||||
$id = data_get($payload, 'repository.id');
|
||||
$pull_request_id = data_get($payload, 'number');
|
||||
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
|
||||
$pull_request_title = data_get($payload, 'pull_request.title');
|
||||
$branch = data_get($payload, 'pull_request.head.ref');
|
||||
$base_branch = data_get($payload, 'pull_request.base.ref');
|
||||
$before_sha = data_get($payload, 'before');
|
||||
|
|
@ -300,6 +347,17 @@ public function normal(Request $request)
|
|||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
|
|
@ -311,6 +369,17 @@ public function normal(Request $request)
|
|||
if ($result['status'] === 'queue_full') {
|
||||
return response($result['message'], 429)->header('Retry-After', 60);
|
||||
}
|
||||
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'github',
|
||||
'mode' => 'app',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'github_app_id' => $github_app->id,
|
||||
]);
|
||||
}
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
|
|
@ -360,6 +429,7 @@ public function normal(Request $request)
|
|||
action: $action,
|
||||
pullRequestId: $pull_request_id,
|
||||
pullRequestHtmlUrl: $pull_request_html_url,
|
||||
pullRequestTitle: $pull_request_title ?? null,
|
||||
beforeSha: $before_sha,
|
||||
afterSha: $after_sha,
|
||||
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
|
|
@ -13,6 +14,8 @@
|
|||
|
||||
class Gitlab extends Controller
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
|
|
@ -32,6 +35,9 @@ public function manual(Request $request)
|
|||
}
|
||||
|
||||
if (empty($x_gitlab_token)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid signature.',
|
||||
|
|
@ -58,6 +64,7 @@ public function manual(Request $request)
|
|||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
}
|
||||
if ($x_gitlab_event === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
|
|
@ -66,6 +73,9 @@ public function manual(Request $request)
|
|||
$full_name = data_get($payload, 'project.path_with_namespace');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
if (! $branch) {
|
||||
$return_payloads->push([
|
||||
'status' => 'failed',
|
||||
|
|
@ -101,6 +111,12 @@ public function manual(Request $request)
|
|||
foreach ($applications as $application) {
|
||||
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -110,6 +126,12 @@ public function manual(Request $request)
|
|||
continue;
|
||||
}
|
||||
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
|
||||
auditLogWebhookFailure('gitlab', 'invalid_signature', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'repository' => $full_name ?? null,
|
||||
'event' => $x_gitlab_event,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
|
|
@ -132,6 +154,17 @@ public function manual(Request $request)
|
|||
if ($application->isDeployable()) {
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if ($is_watch_path_triggered || blank($application->watch_paths)) {
|
||||
if ($skip_deploy_commits ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
|
|
@ -150,6 +183,15 @@ public function manual(Request $request)
|
|||
'application_name' => $application->name,
|
||||
]);
|
||||
} else {
|
||||
auditLog('webhook.deployment.queued', [
|
||||
'provider' => 'gitlab',
|
||||
'mode' => 'manual',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
'repository' => $full_name ?? null,
|
||||
]);
|
||||
$return_payloads->push([
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment queued.',
|
||||
|
|
@ -182,6 +224,15 @@ public function manual(Request $request)
|
|||
if ($x_gitlab_event === 'merge_request') {
|
||||
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
|
||||
if ($application->isPRDeployable()) {
|
||||
if ($skip_deploy_pr ?? false) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
$deployment_uuid = new Cuid2;
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if (! $found) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -3075,29 +3075,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);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\GithubApp;
|
||||
|
|
@ -17,6 +18,7 @@
|
|||
|
||||
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
|
@ -31,6 +33,7 @@ public function __construct(
|
|||
public string $action,
|
||||
public int $pullRequestId,
|
||||
public string $pullRequestHtmlUrl,
|
||||
public ?string $pullRequestTitle,
|
||||
public ?string $beforeSha,
|
||||
public ?string $afterSha,
|
||||
public string $commitSha,
|
||||
|
|
@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
|
|||
return;
|
||||
}
|
||||
|
||||
if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PR deployments from public contributors are restricted
|
||||
if (! $application->settings->is_pr_deployments_public_enabled) {
|
||||
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -407,7 +416,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;
|
||||
|
|
@ -471,7 +480,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';
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
81
bootstrap/helpers/audit.php
Normal file
81
bootstrap/helpers/audit.php
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
if (! function_exists('auditLog')) {
|
||||
/**
|
||||
* Write a security-relevant audit entry to the dedicated `audit` log channel.
|
||||
*
|
||||
* Never include secrets (private keys, passwords, tokens, webhook secrets,
|
||||
* signature header values, env-var values) in $context.
|
||||
*
|
||||
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
|
||||
* @param array<string, mixed> $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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
return [
|
||||
'coolify' => [
|
||||
'version' => '4.0.0',
|
||||
'version' => '4.1.0',
|
||||
'helper_version' => '1.0.13',
|
||||
'realtime_version' => '1.0.13',
|
||||
'realtime_version' => '1.0.14',
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
'autoupdate' => env('AUTOUPDATE'),
|
||||
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->integer('connection_timeout')->default(10)->after('deployment_queue_limit');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('server_settings', function (Blueprint $table) {
|
||||
$table->dropColumn('connection_timeout');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,10 +129,9 @@ services:
|
|||
networks:
|
||||
- coolify
|
||||
minio:
|
||||
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
|
||||
image: ghcr.io/coollabsio/maxio:latest
|
||||
pull_policy: always
|
||||
container_name: coolify-minio
|
||||
command: server /data --console-address ":9001"
|
||||
ports:
|
||||
- "${FORWARD_MINIO_PORT:-9000}:9000"
|
||||
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
6
docker/coolify-realtime/package-lock.json
generated
6
docker/coolify-realtime/package-lock.json
generated
|
|
@ -165,9 +165,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.11",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
|
|
|||
|
|
@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => {
|
|||
|
||||
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
|
||||
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
|
||||
wss.on('connection', async (ws, req) => {
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => { ws.isAlive = true; });
|
||||
|
||||
const userId = generateUserId();
|
||||
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
|
||||
ws.userId = userId;
|
||||
const userSession = {
|
||||
ws,
|
||||
userId,
|
||||
ptyProcess: null,
|
||||
isActive: false,
|
||||
authorizedIPs: [],
|
||||
lastActivityAt: Date.now(),
|
||||
authReady: false,
|
||||
pendingMessages: [],
|
||||
};
|
||||
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
|
||||
const connectionContext = {
|
||||
userId,
|
||||
|
|
@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => {
|
|||
hasLaravelSession: Boolean(laravelSession),
|
||||
};
|
||||
|
||||
// Register socket handlers up front so messages sent immediately by the client
|
||||
// (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
|
||||
// below is still pending.
|
||||
ws.on('message', (message) => {
|
||||
if (userSession.authReady) {
|
||||
handleMessage(userSession, message);
|
||||
} else {
|
||||
userSession.pendingMessages.push(message);
|
||||
}
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
|
||||
// Verify presence of required tokens
|
||||
if (!laravelSession || !xsrfToken) {
|
||||
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
|
||||
|
|
@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => {
|
|||
}
|
||||
|
||||
userSessions.set(userId, userSession);
|
||||
userSession.authReady = true;
|
||||
logTerminal('log', 'Terminal websocket connection established.', {
|
||||
...connectionContext,
|
||||
authorizedHostCount: userSession.authorizedIPs.length,
|
||||
bufferedMessages: userSession.pendingMessages.length,
|
||||
});
|
||||
|
||||
ws.on('message', (message) => {
|
||||
handleMessage(userSession, message);
|
||||
});
|
||||
ws.on('error', (err) => handleError(err, userId));
|
||||
ws.on('close', (code, reason) => {
|
||||
logTerminal('log', 'Terminal websocket connection closed.', {
|
||||
userId,
|
||||
code,
|
||||
reason: reason?.toString(),
|
||||
});
|
||||
handleClose(userId);
|
||||
});
|
||||
// Drain any messages that arrived while we were waiting on the IP auth call.
|
||||
while (userSession.pendingMessages.length > 0) {
|
||||
handleMessage(userSession, userSession.pendingMessages.shift());
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) {
|
||||
logTerminal('warn', 'Terminating WS due to missed protocol pong.');
|
||||
return ws.terminate();
|
||||
}
|
||||
ws.isAlive = false;
|
||||
try {
|
||||
ws.ping();
|
||||
} catch (_) {
|
||||
// ignore — close handler will follow
|
||||
}
|
||||
|
||||
const session = ws.userId ? userSessions.get(ws.userId) : null;
|
||||
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
|
||||
const idleMs = Date.now() - session.lastActivityAt;
|
||||
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
|
||||
userId: ws.userId,
|
||||
idleMs,
|
||||
idleTimeoutMs: IDLE_TIMEOUT_MS,
|
||||
});
|
||||
try {
|
||||
ws.send('idle-timeout');
|
||||
} catch (_) {
|
||||
// ignore — close still attempted below
|
||||
}
|
||||
killPtyProcess(ws.userId);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
ws.close(1000, 'Idle timeout');
|
||||
} catch (_) {
|
||||
// ignore — already closed
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
wss.on('close', () => clearInterval(heartbeat));
|
||||
|
||||
const messageHandlers = {
|
||||
message: (session, data) => session.ptyProcess.write(data),
|
||||
message: (session, data) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
session.ptyProcess.write(data);
|
||||
},
|
||||
resize: (session, { cols, rows }) => {
|
||||
session.lastActivityAt = Date.now();
|
||||
cols = cols > 0 ? cols : 80;
|
||||
rows = rows > 0 ? rows : 30;
|
||||
session.ptyProcess.resize(cols, rows)
|
||||
|
|
@ -197,12 +271,6 @@ function handleMessage(userSession, message) {
|
|||
return;
|
||||
}
|
||||
|
||||
logTerminal('log', 'Received websocket message.', {
|
||||
userId: userSession.userId,
|
||||
keys: Object.keys(parsed),
|
||||
isActive: userSession.isActive,
|
||||
});
|
||||
|
||||
Object.entries(parsed).forEach(([key, value]) => {
|
||||
const handler = messageHandlers[key];
|
||||
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
|
||||
|
|
@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) {
|
|||
|
||||
userSession.ptyProcess = ptyProcess;
|
||||
userSession.isActive = true;
|
||||
userSession.lastActivityAt = Date.now();
|
||||
|
||||
ws.send('pty-ready');
|
||||
|
||||
|
|
|
|||
20
openapi.json
20
openapi.json
|
|
@ -4381,8 +4381,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 +4393,8 @@
|
|||
"description": "Number of days to retain backups in S3"
|
||||
},
|
||||
"database_backup_retention_max_storage_s3": {
|
||||
"type": "integer",
|
||||
"description": "Max storage (MB) for S3 backups"
|
||||
"type": "number",
|
||||
"description": "Max storage (GB) for S3 backups"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
|
|
@ -4951,7 +4951,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 +4963,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": {
|
||||
|
|
@ -10545,6 +10545,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"
|
||||
|
|
@ -13349,6 +13353,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"
|
||||
|
|
|
|||
18
openapi.yaml
18
openapi.yaml
|
|
@ -2765,8 +2765,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 +2774,8 @@ paths:
|
|||
type: integer
|
||||
description: 'Number of days to retain backups in S3'
|
||||
database_backup_retention_max_storage_s3:
|
||||
type: integer
|
||||
description: 'Max storage (MB) for S3 backups'
|
||||
type: number
|
||||
description: 'Max storage (GB) for S3 backups'
|
||||
timeout:
|
||||
type: integer
|
||||
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
|
||||
|
|
@ -3160,7 +3160,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 +3169,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
|
||||
|
|
@ -6734,6 +6734,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':
|
||||
|
|
@ -8538,6 +8541,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'
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
|
||||
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
|
||||
ports:
|
||||
- "${SOKETI_PORT:-6001}:6001"
|
||||
- "6002:6002"
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ services:
|
|||
retries: 10
|
||||
timeout: 2s
|
||||
soketi:
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
|
||||
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
|
||||
pull_policy: always
|
||||
container_name: coolify-realtime
|
||||
restart: always
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0"
|
||||
"version": "4.1.0"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0"
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"version": "1.0.13"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.13"
|
||||
"version": "1.0.14"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.21"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
|
|||
maxHeartbeatMisses: 3,
|
||||
// Command buffering for race condition prevention
|
||||
pendingCommand: null,
|
||||
// Last successfully sent SSH command — replayed after a transient reconnect
|
||||
// so the PTY respawns automatically. Cleared on intentional terminations
|
||||
// (pty-exited, idle-timeout, unprocessable).
|
||||
lastSentCommand: null,
|
||||
// Resize handling
|
||||
resizeObserver: null,
|
||||
resizeTimeout: null,
|
||||
|
|
@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
|
|||
focusWhenReady();
|
||||
});
|
||||
|
||||
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
|
||||
|
||||
this.$watch('terminalActive', (active) => {
|
||||
if (!active && this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval);
|
||||
|
|
@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
clearAllTimers() {
|
||||
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearInterval(timer));
|
||||
if (this.keepAliveInterval) {
|
||||
clearInterval(this.keepAliveInterval);
|
||||
}
|
||||
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
|
||||
.forEach(timer => timer && clearTimeout(timer));
|
||||
this.keepAliveInterval = null;
|
||||
this.reconnectInterval = null;
|
||||
this.connectionTimeoutId = null;
|
||||
|
|
@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
|
|||
|
||||
resetTerminal() {
|
||||
if (this.term) {
|
||||
this.$wire.dispatch('error', 'Terminal websocket connection lost.');
|
||||
this.term.reset();
|
||||
this.term.clear();
|
||||
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
|
||||
// Preserve scrollback so the user keeps the context of their previous
|
||||
// session. Print a visible marker so they know where the disconnect
|
||||
// happened. Old PTY shell state cannot be restored — this is purely
|
||||
// a visual carry-over.
|
||||
try {
|
||||
const stamp = new Date().toLocaleTimeString();
|
||||
this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
|
||||
} catch (_) {
|
||||
// ignore — terminal not ready to receive writes
|
||||
}
|
||||
this.pendingWrites = 0;
|
||||
this.paused = false;
|
||||
this.commandBuffer = '';
|
||||
|
|
@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
|
|||
this.connectionTimeoutId = null;
|
||||
}
|
||||
|
||||
// Flush any buffered command from before WebSocket was ready
|
||||
// Flush any buffered command from before WebSocket was ready, otherwise
|
||||
// replay the last command so a transient reconnect respawns the PTY
|
||||
// automatically without requiring the user to click Connect again.
|
||||
if (this.pendingCommand) {
|
||||
this.sendMessage(this.pendingCommand);
|
||||
this.pendingCommand = null;
|
||||
} else if (this.lastSentCommand) {
|
||||
logTerminal('log', '[Terminal] Replaying last command after reconnect.');
|
||||
this.sendMessage(this.lastSentCommand);
|
||||
}
|
||||
|
||||
// (Re)start application-level keepalive on every successful connect.
|
||||
// Server-side WebSocket protocol pings are the primary heartbeat; this
|
||||
// adds a JSON-level ping in case the server-side is older or restarting.
|
||||
if (!this.keepAliveInterval) {
|
||||
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
|
||||
}
|
||||
|
||||
// Start ping timeout monitoring
|
||||
|
|
@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
|
|||
sendMessage(message) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
if (message && message.command) {
|
||||
this.lastSentCommand = message;
|
||||
}
|
||||
} else {
|
||||
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
|
||||
}
|
||||
|
|
@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
handleSocketMessage(event) {
|
||||
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
|
||||
|
||||
// Handle pong responses
|
||||
if (event.data === 'pong') {
|
||||
this.heartbeatMissed = 0;
|
||||
|
|
@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
|
|||
this.term.open(document.getElementById('terminal'));
|
||||
this.term._initialized = true;
|
||||
} else {
|
||||
this.term.reset();
|
||||
// Already initialized — this is a reconnect or a follow-up command.
|
||||
// Preserve scrollback so the user keeps context. Write a visible
|
||||
// separator so the new shell prompt is easy to spot.
|
||||
try {
|
||||
const stamp = new Date().toLocaleTimeString();
|
||||
this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
|
||||
} catch (_) {
|
||||
// ignore — fall through; xterm will render the new prompt anyway
|
||||
}
|
||||
}
|
||||
this.terminalActive = true;
|
||||
this.term.focus();
|
||||
|
|
@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
|
|||
} else if (event.data === 'unprocessable') {
|
||||
if (this.term) this.term.reset();
|
||||
this.terminalActive = false;
|
||||
this.lastSentCommand = null;
|
||||
this.message = '(sorry, something went wrong, please try again)';
|
||||
|
||||
// Notify parent component that terminal connection failed
|
||||
|
|
@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
|
|||
this.terminalActive = false;
|
||||
this.term.reset();
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
|
||||
// Notify parent component that terminal disconnected
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (event.data === 'idle-timeout') {
|
||||
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
|
||||
this.terminalActive = false;
|
||||
if (this.term) {
|
||||
this.term.reset();
|
||||
}
|
||||
this.commandBuffer = '';
|
||||
this.lastSentCommand = null;
|
||||
this.$wire.dispatch('terminalDisconnected');
|
||||
} else if (
|
||||
typeof event.data === 'string' &&
|
||||
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
|
||||
|
|
@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
|
|||
},
|
||||
|
||||
keepAlive() {
|
||||
// Skip keepalive when document is hidden to prevent unnecessary disconnects
|
||||
if (!this.isDocumentVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMessage({ ping: true });
|
||||
} else if (this.connectionState === 'disconnected') {
|
||||
|
|
@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
|
|||
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
|
||||
|
||||
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
// Send immediate ping to verify connection is still alive
|
||||
// Connection may be half-open after Cloudflare/proxy idle drop while hidden.
|
||||
// Probe with a short timeout (5s) instead of the default 35s — force a
|
||||
// reconnect quickly if no pong arrives so the user is not stuck typing
|
||||
// into a dead socket.
|
||||
this.heartbeatMissed = 0;
|
||||
this.sendMessage({ ping: true });
|
||||
this.resetPingTimeout();
|
||||
if (this.pingTimeoutId) {
|
||||
clearTimeout(this.pingTimeoutId);
|
||||
}
|
||||
this.pingTimeoutId = setTimeout(() => {
|
||||
logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
|
||||
try {
|
||||
this.socket.close(4000, 'Visibility-resume timeout');
|
||||
} catch (_) {
|
||||
// ignore — close handler will run on its own
|
||||
}
|
||||
}, 5000);
|
||||
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
|
||||
// Was connected before but now disconnected - attempt reconnection
|
||||
this.reconnectAttempts = 0;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
fullscreen: @entangle('fullscreen'),
|
||||
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
|
||||
rafId: null,
|
||||
scrollDebounce: null,
|
||||
isScrolling: false,
|
||||
lastTouchY: 0,
|
||||
showTimestamps: true,
|
||||
searchQuery: '',
|
||||
matchCount: 0,
|
||||
|
|
@ -19,9 +22,54 @@
|
|||
scrollToBottom() {
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
if (logsContainer) {
|
||||
this.isScrolling = true;
|
||||
logsContainer.scrollTop = logsContainer.scrollHeight;
|
||||
setTimeout(() => { this.isScrolling = false; }, 50);
|
||||
}
|
||||
},
|
||||
disableFollow() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
},
|
||||
handleWheel(event) {
|
||||
if (this.alwaysScroll && event.deltaY < 0) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleTouchStart(event) {
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const currentY = event.touches[0].clientY;
|
||||
if (currentY > this.lastTouchY) {
|
||||
this.disableFollow();
|
||||
}
|
||||
this.lastTouchY = currentY;
|
||||
},
|
||||
handleKeyScroll(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
|
||||
if (upKeys.includes(event.key)) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleScroll(event) {
|
||||
if (this.isScrolling) return;
|
||||
clearTimeout(this.scrollDebounce);
|
||||
this.scrollDebounce = setTimeout(() => {
|
||||
const el = event.target;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (!this.alwaysScroll && distanceFromBottom <= 10) {
|
||||
this.alwaysScroll = true;
|
||||
this.scheduleScroll();
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
scheduleScroll() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.rafId = requestAnimationFrame(() => {
|
||||
|
|
@ -327,7 +375,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer"
|
||||
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
|
||||
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
|
||||
class="flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar">
|
||||
<div id="logs" class="flex flex-col font-logs">
|
||||
<div x-show="searchQuery.trim() && matchCount === 0"
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
min="0"
|
||||
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
|
||||
type="number" min="0"
|
||||
type="number" min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
min="0"
|
||||
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
|
||||
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
|
||||
type="number" min="0"
|
||||
type="number" min="0" step="any"
|
||||
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
|
||||
@if ($isReadOnly)
|
||||
@if ($fileStorage->is_too_large)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
|
||||
</div>
|
||||
@elseif ($isReadOnly)
|
||||
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
|
||||
@if ($fileStorage->is_directory)
|
||||
This directory is mounted as read-only and cannot be modified from the UI.
|
||||
|
|
@ -44,7 +48,7 @@
|
|||
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
|
||||
shortConfirmationLabel="Filepath" />
|
||||
@else
|
||||
@if (!$fileStorage->is_binary)
|
||||
@if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
|
||||
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
|
||||
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
|
||||
'The selected file will be permanently deleted and an empty directory will be created in its place.',
|
||||
|
|
@ -76,8 +80,8 @@
|
|||
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
|
||||
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
|
||||
rows="20" id="content"
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
|
||||
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
|
||||
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
|
||||
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
|
||||
@endif
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -28,6 +28,38 @@
|
|||
}
|
||||
},
|
||||
isScrolling: false,
|
||||
lastTouchY: 0,
|
||||
disableFollow() {
|
||||
if (!this.alwaysScroll) return;
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
},
|
||||
handleWheel(event) {
|
||||
if (this.alwaysScroll && event.deltaY < 0) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
handleTouchStart(event) {
|
||||
this.lastTouchY = event.touches[0].clientY;
|
||||
},
|
||||
handleTouchMove(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const currentY = event.touches[0].clientY;
|
||||
if (currentY > this.lastTouchY) {
|
||||
this.disableFollow();
|
||||
}
|
||||
this.lastTouchY = currentY;
|
||||
},
|
||||
handleKeyScroll(event) {
|
||||
if (!this.alwaysScroll) return;
|
||||
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
|
||||
if (upKeys.includes(event.key)) {
|
||||
this.disableFollow();
|
||||
}
|
||||
},
|
||||
scrollToBottom() {
|
||||
const logsContainer = document.getElementById('logsContainer');
|
||||
if (logsContainer) {
|
||||
|
|
@ -57,17 +89,14 @@
|
|||
}
|
||||
},
|
||||
handleScroll(event) {
|
||||
if (!this.alwaysScroll || this.isScrolling) return;
|
||||
if (this.isScrolling) return;
|
||||
clearTimeout(this.scrollDebounce);
|
||||
this.scrollDebounce = setTimeout(() => {
|
||||
const el = event.target;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom > 100) {
|
||||
this.alwaysScroll = false;
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId);
|
||||
this.rafId = null;
|
||||
}
|
||||
if (!this.alwaysScroll && distanceFromBottom <= 10) {
|
||||
this.alwaysScroll = true;
|
||||
this.scheduleScroll();
|
||||
}
|
||||
}, 150);
|
||||
},
|
||||
|
|
@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="logsContainer" @scroll="handleScroll"
|
||||
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
|
||||
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
|
||||
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
|
||||
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
|
||||
@if ($outputs)
|
||||
|
|
|
|||
|
|
@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
|
|||
label="Port" required :disabled="$isValidating" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-64">
|
||||
<x-forms.input canGate="update" :canResource="$server" type="number"
|
||||
id="connectionTimeout" label="SSH Connection Timeout (s)"
|
||||
helper="Seconds to wait for SSH connection before failing. Default: 10."
|
||||
min="1" max="300" required :disabled="$isValidating" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<div class="flex items-center mb-1">
|
||||
<label for="serverTimezone">Server Timezone</label>
|
||||
|
|
|
|||
|
|
@ -215,6 +215,8 @@
|
|||
Route::post('/sentinel/push', function () {
|
||||
$token = request()->header('Authorization');
|
||||
if (! $token) {
|
||||
auditLogWebhookFailure('sentinel', 'token_missing');
|
||||
|
||||
return response()->json(['message' => 'Unauthorized'], 401);
|
||||
}
|
||||
$naked_token = str_replace('Bearer ', '', $token);
|
||||
|
|
@ -222,26 +224,49 @@
|
|||
$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);
|
||||
}
|
||||
$data = request()->all();
|
||||
|
|
@ -249,6 +274,11 @@
|
|||
// \App\Jobs\ServerCheckNewJob::dispatch($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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
79
tests/Feature/OauthControllerTest.php
Normal file
79
tests/Feature/OauthControllerTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::create([
|
||||
'id' => 0,
|
||||
'is_registration_enabled' => false,
|
||||
]);
|
||||
|
||||
OauthSetting::create([
|
||||
'provider' => 'google',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://coolify.example.com/auth/google/callback',
|
||||
'tenant' => 'example.com',
|
||||
]);
|
||||
});
|
||||
|
||||
it('logs in an existing user when the oauth provider returns a mixed-case email', function () {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'username@example.edu',
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => 'UserName@example.edu',
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/');
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(User::count())->toBe(1);
|
||||
});
|
||||
|
||||
it('rejects oauth logins when the provider does not return an email address', function (?string $providerEmail) {
|
||||
config()->set('app.maintenance.driver', 'file');
|
||||
InstanceSettings::firstOrCreate([
|
||||
'id' => 0,
|
||||
], [
|
||||
'is_registration_enabled' => false,
|
||||
])->update([
|
||||
'is_registration_enabled' => true,
|
||||
]);
|
||||
|
||||
$provider = \Mockery::mock();
|
||||
$provider->shouldReceive('setConfig')->once()->andReturnSelf();
|
||||
$provider->shouldReceive('with')->once()->with(['hd' => 'example.com'])->andReturnSelf();
|
||||
$provider->shouldReceive('user')->once()->andReturn((object) [
|
||||
'email' => $providerEmail,
|
||||
'name' => 'Example User',
|
||||
'id' => 'google-user-id',
|
||||
]);
|
||||
|
||||
Socialite::shouldReceive('driver')->once()->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->from('/login')->get(route('auth.callback', 'google'));
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
expect(User::count())->toBe(0);
|
||||
})->with([
|
||||
'null email' => [null],
|
||||
'blank email' => [' '],
|
||||
]);
|
||||
107
tests/Feature/QueueApplicationDeploymentCommitTest.php
Normal file
107
tests/Feature/QueueApplicationDeploymentCommitTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Bus::fake([ApplicationDeploymentJob::class]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = StandaloneDocker::factory()->create([
|
||||
'server_id' => $this->server->id,
|
||||
'network' => 'test-network-'.fake()->unique()->word(),
|
||||
]);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
});
|
||||
|
||||
function makeApplication(int $environmentId, int $destinationId, ?string $gitCommitSha): Application
|
||||
{
|
||||
$attributes = [
|
||||
'environment_id' => $environmentId,
|
||||
'destination_id' => $destinationId,
|
||||
'destination_type' => StandaloneDocker::class,
|
||||
];
|
||||
|
||||
if ($gitCommitSha !== null) {
|
||||
$attributes['git_commit_sha'] = $gitCommitSha;
|
||||
}
|
||||
|
||||
return Application::factory()->create($attributes);
|
||||
}
|
||||
|
||||
describe('queue_application_deployment commit resolution', function () {
|
||||
test('uses application git_commit_sha when commit parameter omitted', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-1',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-1')->first();
|
||||
expect($deployment)->not->toBeNull();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
|
||||
test('falls back to HEAD when both commit parameter and git_commit_sha are unset', function () {
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, 'HEAD');
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-2',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-2')->first();
|
||||
expect($deployment->commit)->toBe('HEAD');
|
||||
});
|
||||
|
||||
test('explicit commit parameter overrides application git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$webhookSha = '111222333444555666777888999000aaabbbccc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-3',
|
||||
commit: $webhookSha,
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-3')->first();
|
||||
expect($deployment->commit)->toBe($webhookSha);
|
||||
});
|
||||
|
||||
test('treats empty string commit parameter as unset and uses git_commit_sha', function () {
|
||||
$pinnedSha = 'abc123def456abc123def456abc123def456abc1';
|
||||
$application = makeApplication($this->environment->id, $this->destination->id, $pinnedSha);
|
||||
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: 'test-deploy-uuid-4',
|
||||
commit: '',
|
||||
);
|
||||
|
||||
expect($result['status'])->toBe('queued');
|
||||
|
||||
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', 'test-deploy-uuid-4')->first();
|
||||
expect($deployment->commit)->toBe($pinnedSha);
|
||||
});
|
||||
});
|
||||
|
|
@ -32,3 +32,75 @@
|
|||
->toContain('if (!terminalDebugEnabled) {')
|
||||
->not->toContain("console.log('Coolify realtime terminal server listening on port 6002. Let the hacking begin!');");
|
||||
});
|
||||
|
||||
it('configures a server-initiated WebSocket heartbeat to survive proxy idle timeouts', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('ws.isAlive = true;')
|
||||
->toContain("ws.on('pong'")
|
||||
->toContain('ws.ping();')
|
||||
->toContain('ws.terminate();')
|
||||
->toContain('HEARTBEAT_INTERVAL_MS');
|
||||
});
|
||||
|
||||
it('removes the keepalive short-circuit that fired when the tab was hidden', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->not->toContain('// Skip keepalive when document is hidden to prevent unnecessary disconnects');
|
||||
});
|
||||
|
||||
it('uses a fast probe timeout when the tab regains visibility', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("'Visibility-resume timeout'");
|
||||
});
|
||||
|
||||
it('closes idle terminal sessions after 30 minutes on the server', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('IDLE_TIMEOUT_MS = 30 * 60 * 1000')
|
||||
->toContain('lastActivityAt')
|
||||
->toContain("ws.send('idle-timeout');")
|
||||
->toContain("ws.close(1000, 'Idle timeout');");
|
||||
});
|
||||
|
||||
it('reacts to idle-timeout sentinel on the client and shows a user-facing error', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain("event.data === 'idle-timeout'")
|
||||
->toContain('Terminal closed after 30 minutes of inactivity.');
|
||||
});
|
||||
|
||||
it('replays the last command on reconnect so the PTY respawns automatically', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('lastSentCommand')
|
||||
->toContain('Replaying last command after reconnect.')
|
||||
->toContain('this.lastSentCommand = null;');
|
||||
});
|
||||
|
||||
it('buffers messages received before the realtime server finishes auth so the replay is not lost', function () {
|
||||
$terminalServer = file_get_contents(base_path('docker/coolify-realtime/terminal-server.js'));
|
||||
|
||||
expect($terminalServer)
|
||||
->toContain('authReady: false')
|
||||
->toContain('pendingMessages: []')
|
||||
->toContain('userSession.pendingMessages.push(message)')
|
||||
->toContain('userSession.authReady = true');
|
||||
});
|
||||
|
||||
it('preserves terminal scrollback across transient reconnects', function () {
|
||||
$terminalClient = file_get_contents(base_path('resources/js/terminal.js'));
|
||||
|
||||
expect($terminalClient)
|
||||
->toContain('── Connection lost at')
|
||||
->toContain('── Reconnected at')
|
||||
// resetTerminal must NOT call term.reset()/term.clear() any more — those wipe scrollback.
|
||||
->not->toContain("this.term.reset();\n this.term.clear();");
|
||||
});
|
||||
|
|
|
|||
445
tests/Feature/Security/AuditLogTest.php
Normal file
445
tests/Feature/Security/AuditLogTest.php
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function makeAuditTeamUser(): array
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$team->members()->attach($user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $team]);
|
||||
test()->actingAs($user);
|
||||
|
||||
return [$team, $user];
|
||||
}
|
||||
|
||||
function makeAuditApiToken(User $user, Team $team, array $abilities = ['root']): string
|
||||
{
|
||||
$token = $user->createToken('audit-test', $abilities);
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
return $token->plainTextToken;
|
||||
}
|
||||
|
||||
function makeAuditApplication(string $repo = 'test-org/test-repo'): Application
|
||||
{
|
||||
$team = Team::factory()->create();
|
||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
|
||||
return Application::create([
|
||||
'name' => 'audit-test-app',
|
||||
'git_repository' => "https://github.com/{$repo}",
|
||||
'git_branch' => 'main',
|
||||
'build_pack' => 'nixpacks',
|
||||
'ports_exposes' => '3000',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
}
|
||||
|
||||
describe('audit channel helper', function () {
|
||||
test('auditLog writes structured payload to audit channel', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with('test.event', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'test.event'
|
||||
&& $context['custom_field'] === 'value'
|
||||
&& array_key_exists('ip', $context)
|
||||
&& array_key_exists('user_id', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.event', ['custom_field' => 'value']);
|
||||
});
|
||||
|
||||
test('auditLog warning level routes correctly', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')->once()->with('test.failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.failed', [], 'warning');
|
||||
});
|
||||
|
||||
test('auditLogWebhookFailure logs warning with provider tag', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::on(function ($context) {
|
||||
return $context['reason'] === 'invalid_signature'
|
||||
&& $context['event'] === 'webhook.github.signature_failed'
|
||||
&& array_key_exists('ip', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLogWebhookFailure('github', 'invalid_signature', ['extra' => 'context']);
|
||||
});
|
||||
|
||||
test('auditLog never includes raw secret keys in context', function () {
|
||||
$captured = null;
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->once()
|
||||
->with(Mockery::any(), Mockery::on(function ($context) use (&$captured) {
|
||||
$captured = $context;
|
||||
|
||||
return true;
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
|
||||
auditLog('test.private_key.created', [
|
||||
'team_id' => '1',
|
||||
'private_key_uuid' => 'abc',
|
||||
'fingerprint' => 'SHA256:xyz',
|
||||
]);
|
||||
|
||||
expect($captured)->toBeArray();
|
||||
// Helper itself never injects secret-bearing keys.
|
||||
$disallowed = ['private_key', 'password', 'token', 'webhook_secret', 'signature', 'client_secret'];
|
||||
foreach (array_keys($captured) as $key) {
|
||||
expect(in_array(strtolower($key), $disallowed, true))->toBeFalse();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('webhook signature failure logging', function () {
|
||||
test('GitHub manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.github.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/github/events/manual', [], [], [], [
|
||||
'HTTP_X-GitHub-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('GitLab manual webhook with bad token logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitlab.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/webhooks/source/gitlab/events/manual', [
|
||||
'object_kind' => 'push',
|
||||
'ref' => 'refs/heads/main',
|
||||
'project' => ['path_with_namespace' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
], [
|
||||
'X-Gitlab-Token' => 'wrong-token',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Bitbucket manual webhook with malformed signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.bitbucket.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'push' => ['changes' => [['new' => ['name' => 'main', 'target' => ['hash' => 'abc123']]]]],
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/bitbucket/events/manual', [], [], [], [
|
||||
'HTTP_X-Event-Key' => 'repo:push',
|
||||
'HTTP_X-Hub-Signature' => 'sha1=anyvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
|
||||
test('Gitea manual webhook with bad signature logs to audit channel', function () {
|
||||
$app = makeAuditApplication();
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.gitea.signature_failed', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$payload = json_encode([
|
||||
'ref' => 'refs/heads/main',
|
||||
'repository' => ['full_name' => 'test-org/test-repo'],
|
||||
'after' => 'abc123',
|
||||
'commits' => [],
|
||||
]);
|
||||
|
||||
$response = $this->call('POST', '/webhooks/source/gitea/events/manual', [], [], [], [
|
||||
'HTTP_X-Gitea-Event' => 'push',
|
||||
'HTTP_X-Hub-Signature-256' => 'sha256=forgedhashvalue',
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
], $payload);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->getContent())->toContain('Invalid signature');
|
||||
});
|
||||
});
|
||||
|
||||
describe('API mutation audit logging', function () {
|
||||
test('private key creation emits api.private_key.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.private_key.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.private_key.created'
|
||||
&& ! array_key_exists('private_key', $context);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
// Generate a valid OpenSSH-format private key for the test.
|
||||
$opensshKey = "-----BEGIN OPENSSH PRIVATE KEY-----\n".
|
||||
base64_encode(str_repeat('a', 256)).
|
||||
"\n-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/security/keys', [
|
||||
'name' => 'test-key',
|
||||
'description' => 'audit test',
|
||||
'private_key' => $opensshKey,
|
||||
]);
|
||||
|
||||
// Either 201 or 422 acceptable depending on validation; the assertion above verifies log if 201.
|
||||
expect($response->status())->toBeIn([201, 422]);
|
||||
});
|
||||
|
||||
test('enable_api denial for non-root team emits warning audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.instance.enable_denied', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
])->getJson('/api/v1/enable');
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('project creation emits api.project.created audit event', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = makeAuditApiToken($user, $team);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('info')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.project.created', Mockery::on(function ($context) {
|
||||
return $context['event'] === 'api.project.created'
|
||||
&& ! empty($context['project_uuid'])
|
||||
&& $context['project_name'] === 'audit-project';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'audit-project',
|
||||
'description' => 'audit',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('threat-detection audit logging (Phase 2)', function () {
|
||||
test('missing bearer token logs api.auth.unauthenticated', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('expired bearer token logs api.auth.unauthenticated', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$token = $user->createToken('expired-audit', ['read'], now()->subDay());
|
||||
DB::table('personal_access_tokens')->where('id', $token->accessToken->id)->update([
|
||||
'team_id' => $team->id,
|
||||
]);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.unauthenticated', Mockery::any());
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$token->plainTextToken,
|
||||
])->getJson('/api/v1/projects');
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('read-only token hitting write endpoint logs api.auth.ability_denied', function () {
|
||||
[$team, $user] = makeAuditTeamUser();
|
||||
$readToken = makeAuditApiToken($user, $team, ['read']);
|
||||
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('api.auth.ability_denied', Mockery::on(function ($ctx) {
|
||||
return in_array('write', $ctx['required_abilities'] ?? [], true);
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$readToken,
|
||||
'Content-Type' => 'application/json',
|
||||
])->postJson('/api/v1/projects', [
|
||||
'name' => 'should-fail',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
});
|
||||
|
||||
test('sentinel push without Authorization logs token_missing', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'token_missing';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
|
||||
test('sentinel push with un-decryptable bearer logs decrypt_failed', function () {
|
||||
$auditChannel = Mockery::mock();
|
||||
$auditChannel->shouldReceive('warning')
|
||||
->atLeast()
|
||||
->once()
|
||||
->with('webhook.sentinel.signature_failed', Mockery::on(function ($ctx) {
|
||||
return $ctx['reason'] === 'decrypt_failed';
|
||||
}));
|
||||
|
||||
Log::shouldReceive('channel')->with('audit')->andReturn($auditChannel);
|
||||
Log::shouldReceive('warning')->andReturnNull();
|
||||
Log::shouldReceive('info')->andReturnNull();
|
||||
Log::shouldReceive('error')->andReturnNull();
|
||||
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer not-a-valid-encrypted-payload',
|
||||
])->postJson('/api/v1/sentinel/push', []);
|
||||
|
||||
$response->assertStatus(401);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
use App\Models\ServerSetting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
|
|
@ -78,11 +79,73 @@
|
|||
expect(ServerSetting::isValidSentinelToken(''))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns false for null sentinel token', function () {
|
||||
expect(ServerSetting::isValidSentinelToken(null))->toBeFalse();
|
||||
});
|
||||
|
||||
it('rejects the reported PoC payload', function () {
|
||||
expect(ServerSetting::isValidSentinelToken('abc" ; id >/tmp/coolify_poc_sentinel ; echo "'))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerSetting::ensureValidSentinelToken', function () {
|
||||
it('regenerates empty sentinel token via ensureValidSentinelToken', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||
|
||||
$settings->refresh();
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||
});
|
||||
|
||||
it('regenerates token when stored value cannot be decrypted', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => 'not-encrypted-junk']);
|
||||
|
||||
$settings->refresh();
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
expect($settings->fresh()->sentinel_token)->toBe($token);
|
||||
});
|
||||
|
||||
it('returns existing valid token without regenerating', function () {
|
||||
$settings = $this->server->settings;
|
||||
$original = $settings->sentinel_token;
|
||||
|
||||
$token = $settings->ensureValidSentinelToken();
|
||||
|
||||
expect($token)->toBe($original);
|
||||
});
|
||||
|
||||
it('throws RuntimeException only when regeneration also fails', function () {
|
||||
$settings = $this->server->settings;
|
||||
DB::table('server_settings')->where('id', $settings->id)->update(['sentinel_token' => '']);
|
||||
|
||||
$stub = new class extends ServerSetting
|
||||
{
|
||||
protected $table = 'server_settings';
|
||||
|
||||
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
|
||||
{
|
||||
DB::table('server_settings')->where('id', $this->id)->update([
|
||||
'sentinel_token' => encrypt('invalid token with spaces!'),
|
||||
]);
|
||||
|
||||
return '';
|
||||
}
|
||||
};
|
||||
$stub->setRawAttributes($settings->fresh()->getAttributes(), true);
|
||||
$stub->exists = true;
|
||||
|
||||
expect(fn () => $stub->ensureValidSentinelToken())
|
||||
->toThrow(RuntimeException::class, 'Sentinel token invalid after regeneration');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generated sentinel tokens are valid', function () {
|
||||
it('generates tokens that pass format validation', function () {
|
||||
$settings = $this->server->settings;
|
||||
|
|
@ -92,4 +155,11 @@
|
|||
expect($token)->not->toBeEmpty();
|
||||
expect(ServerSetting::isValidSentinelToken($token))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns the same value the cast reads back', function () {
|
||||
$settings = $this->server->settings;
|
||||
$returned = $settings->generateSentinelToken(save: true, ignoreEvent: true);
|
||||
|
||||
expect($settings->fresh()->sentinel_token)->toBe($returned);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
tests/Feature/ServerConnectionTimeoutApiTest.php
Normal file
74
tests/Feature/ServerConnectionTimeoutApiTest.php
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
|
||||
$newToken = $this->user->createToken('write-token', ['write']);
|
||||
$newToken->accessToken->forceFill(['team_id' => $this->team->id])->save();
|
||||
$this->token = $newToken->plainTextToken;
|
||||
});
|
||||
|
||||
it('PATCH updates connection_timeout via API', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 45,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(45);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout out of range', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 0,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects connection_timeout above max', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 999,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
|
||||
it('PATCH rejects non-integer connection_timeout', function () {
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer '.$this->token,
|
||||
'Content-Type' => 'application/json',
|
||||
])->patchJson('/api/v1/servers/'.$this->server->uuid, [
|
||||
'connection_timeout' => 'fast',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonStructure(['errors' => ['connection_timeout']]);
|
||||
});
|
||||
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal file
43
tests/Feature/ServerConnectionTimeoutTest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$user = User::factory()->create();
|
||||
$this->team = $user->teams()->first();
|
||||
$this->server = Server::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults connection_timeout to 10 seconds for new servers', function () {
|
||||
expect($this->server->settings->connection_timeout)->toBe(10);
|
||||
});
|
||||
|
||||
it('persists a custom connection_timeout value', function () {
|
||||
$this->server->settings->connection_timeout = 30;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect($this->server->settings->fresh()->connection_timeout)->toBe(30);
|
||||
});
|
||||
|
||||
it('returns the per-server connection_timeout from getConnectionTimeout', function () {
|
||||
$this->server->settings->connection_timeout = 45;
|
||||
$this->server->settings->save();
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe(45);
|
||||
});
|
||||
|
||||
it('falls back to config default when connection_timeout is invalid', function () {
|
||||
$this->server->settings->connection_timeout = 0;
|
||||
$this->server->settings->saveQuietly();
|
||||
|
||||
$expected = (int) config('constants.ssh.connection_timeout');
|
||||
|
||||
expect(SshMultiplexingHelper::getConnectionTimeout($this->server->fresh()))->toBe($expected);
|
||||
});
|
||||
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
105
tests/Feature/ServerReachabilityNotificationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Notifications\Channels\EmailChannel;
|
||||
use App\Notifications\Server\Reachable;
|
||||
use App\Notifications\Server\Unreachable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->emailNotificationSettings()->update([
|
||||
'use_instance_email_settings' => true,
|
||||
'server_unreachable_email_notifications' => true,
|
||||
'server_reachable_email_notifications' => true,
|
||||
]);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
|
||||
Notification::fake();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when threshold reached and not yet notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not send Unreachable on first transient failure (count=1)', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 1,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does not send Unreachable when already notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 5,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('sends Reachable notification on recovery when previously notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => true,
|
||||
])->save();
|
||||
|
||||
$fresh = $this->server->fresh();
|
||||
expect($fresh->unreachable_notification_sent)->toBeTrue();
|
||||
expect((bool) $fresh->settings->is_reachable)->toBeTrue();
|
||||
|
||||
ServerReachabilityChanged::dispatch($fresh);
|
||||
|
||||
Notification::assertSentTo($this->team, Reachable::class);
|
||||
expect($this->server->fresh()->unreachable_notification_sent)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not send Reachable when never notified', function () {
|
||||
$this->server->settings()->update(['is_reachable' => true]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 0,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('routes Unreachable notification through EmailChannel when email toggle is on', function () {
|
||||
$this->server->settings()->update(['is_reachable' => false]);
|
||||
$this->server->forceFill([
|
||||
'unreachable_count' => 2,
|
||||
'unreachable_notification_sent' => false,
|
||||
])->save();
|
||||
|
||||
ServerReachabilityChanged::dispatch($this->server->fresh());
|
||||
|
||||
Notification::assertSentTo($this->team, Unreachable::class, function ($notification, $channels) {
|
||||
return in_array(EmailChannel::class, $channels);
|
||||
});
|
||||
});
|
||||
65
tests/Feature/SuppressHorizonJobFailuresTest.php
Normal file
65
tests/Feature/SuppressHorizonJobFailuresTest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
use App\Exceptions\DeploymentException;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Laravel\Horizon\Contracts\JobRepository;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
function fakeJob(string $uuid): Job
|
||||
{
|
||||
$job = Mockery::mock(Job::class)->shouldIgnoreMissing();
|
||||
$job->shouldReceive('uuid')->andReturn($uuid);
|
||||
$job->shouldReceive('getJobId')->andReturn($uuid);
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function fireJobFailed(Job $job, Throwable $exception): void
|
||||
{
|
||||
event(new JobFailed('redis', $job, $exception));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
config(['constants.coolify.self_hosted' => false]);
|
||||
});
|
||||
|
||||
test('scrubs Horizon failed entry for DeploymentException on cloud', function () {
|
||||
$uuid = 'uuid-deployment-1';
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
|
||||
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob($uuid), new DeploymentException('build failed'));
|
||||
});
|
||||
|
||||
test('scrubs Horizon failed entry for TimeoutExceededException on cloud', function () {
|
||||
$uuid = 'uuid-timeout-1';
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) use ($uuid) {
|
||||
$mock->shouldReceive('deleteFailed')->once()->with($uuid);
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob($uuid), new TimeoutExceededException('worker timeout'));
|
||||
});
|
||||
|
||||
test('does not scrub generic exceptions on cloud', function () {
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) {
|
||||
$mock->shouldNotReceive('deleteFailed');
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob('uuid-generic-1'), new RuntimeException('boom'));
|
||||
});
|
||||
|
||||
test('does not scrub when self-hosted even for filtered exceptions', function () {
|
||||
config(['constants.coolify.self_hosted' => true]);
|
||||
|
||||
$this->mock(JobRepository::class, function (MockInterface $mock) {
|
||||
$mock->shouldNotReceive('deleteFailed');
|
||||
});
|
||||
|
||||
fireJobFailed(fakeJob('uuid-deployment-2'), new DeploymentException('build failed'));
|
||||
fireJobFailed(fakeJob('uuid-timeout-2'), new TimeoutExceededException('worker timeout'));
|
||||
});
|
||||
115
tests/Unit/DetectsSkipDeployCommitsTest.php
Normal file
115
tests/Unit/DetectsSkipDeployCommitsTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
|
||||
$harness = new class
|
||||
{
|
||||
use DetectsSkipDeployCommits;
|
||||
};
|
||||
|
||||
$harnessClass = get_class($harness);
|
||||
|
||||
describe('shouldSkipDeploy (all-must-match)', function () use ($harnessClass) {
|
||||
test('returns false when messages array is empty', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy([]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when all messages contain [skip ci]', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Update docs [skip ci]',
|
||||
'Fix typo [skip ci]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when single message contains [skip cd]', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['Update README [skip cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true with mixed [skip ci] and [skip cd] (case-insensitive)', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Docs [SKIP CI]',
|
||||
'Changelog [Skip Cd]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns false when at least one message has no skip marker', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Update docs [skip ci]',
|
||||
'Actual feature change',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when single message has no skip marker', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['Deploy this please']))->toBeFalse();
|
||||
});
|
||||
|
||||
test('null entries are filtered before evaluation', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
null,
|
||||
'Docs [skip ci]',
|
||||
null,
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeploy($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('matches PR title scenario (single string)', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeploy(['chore: update readme [skip ci]']))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeploy(['feat: real change']))->toBeFalse();
|
||||
expect($harnessClass::shouldSkipDeploy([null]))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldSkipDeployAny (any-marker)', function () use ($harnessClass) {
|
||||
test('returns false when messages array is empty', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns false when only nulls or empty strings are provided', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('returns true when any one message contains [skip ci]', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'Real feature change',
|
||||
'docs: update readme [skip ci]',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeployAny($messages))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true when any one message contains [skip cd]', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feature change', 'chore [skip cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns true case-insensitively', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: docs [SKIP CI]']))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: docs [Skip Cd]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('returns false when no message contains a skip marker', function () use ($harnessClass) {
|
||||
$messages = [
|
||||
'feat: add new endpoint',
|
||||
'fix: handle edge case',
|
||||
];
|
||||
expect($harnessClass::shouldSkipDeployAny($messages))->toBeFalse();
|
||||
});
|
||||
|
||||
test('null and empty entries are skipped, real markers still match', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', 'docs [skip ci]', null]))->toBeTrue();
|
||||
expect($harnessClass::shouldSkipDeployAny([null, '', null]))->toBeFalse();
|
||||
});
|
||||
|
||||
test('PR title alone with skip marker triggers skip', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['chore: update readme [skip ci]']))->toBeTrue();
|
||||
});
|
||||
|
||||
test('PR title without skip marker but commit message with skip marker triggers skip', function () use ($harnessClass) {
|
||||
expect($harnessClass::shouldSkipDeployAny(['feat: real change', 'wip [skip cd]']))->toBeTrue();
|
||||
});
|
||||
});
|
||||
61
tests/Unit/IsReachableChangedTest.php
Normal file
61
tests/Unit/IsReachableChangedTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Server;
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
function makeServerForReachabilityTest(bool $isReachable, bool $notificationSent, int $unreachableCount): Server
|
||||
{
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = $isReachable;
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('refresh')->andReturnSelf();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn($notificationSent);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
it('sends Reachable notification when reachable and notification was previously sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: true, unreachableCount: 0);
|
||||
$server->shouldReceive('sendReachableNotification')->once();
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send any notification when reachable and notification was never sent', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: true, notificationSent: false, unreachableCount: 0);
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('sends Unreachable notification when count >= 2 and not yet notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 2);
|
||||
$server->shouldReceive('sendUnreachableNotification')->once();
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not send Unreachable notification on first transient failure (count=1)', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: false, unreachableCount: 1);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
|
||||
it('does not double-send Unreachable when already notified', function () {
|
||||
$server = makeServerForReachabilityTest(isReachable: false, notificationSent: true, unreachableCount: 5);
|
||||
$server->shouldNotReceive('sendUnreachableNotification');
|
||||
$server->shouldNotReceive('sendReachableNotification');
|
||||
|
||||
$server->isReachableChanged();
|
||||
});
|
||||
66
tests/Unit/LocalFileVolumeContentSizeTest.php
Normal file
66
tests/Unit/LocalFileVolumeContentSizeTest.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Unit tests for LocalFileVolume content size handling.
|
||||
*
|
||||
* Related Issue: #4701 - Storages page becomes unusable when Docker volumes
|
||||
* mount large host files. Coolify previously stored full file content in the
|
||||
* encrypted `content` mediumText column, then serialized it to the Livewire
|
||||
* payload, crashing the browser.
|
||||
*/
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('exposes a 5 MiB content size limit', function () {
|
||||
expect(LocalFileVolume::MAX_CONTENT_SIZE)->toBe(5_242_880);
|
||||
});
|
||||
|
||||
it('exposes binary and too-large placeholder constants', function () {
|
||||
expect(LocalFileVolume::BINARY_PLACEHOLDER)->toBe('[binary file]');
|
||||
expect(LocalFileVolume::TOO_LARGE_PLACEHOLDER)->toBe('[file too large to display]');
|
||||
});
|
||||
|
||||
it('flags is_too_large when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_too_large)->toBeTrue();
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
});
|
||||
|
||||
it('flags is_binary when content matches the placeholder', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::BINARY_PLACEHOLDER;
|
||||
|
||||
expect($volume->is_binary)->toBeTrue();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag normal content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = "hello\nworld\n";
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not flag empty content as binary or too large', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = null;
|
||||
|
||||
expect($volume->is_binary)->toBeFalse();
|
||||
expect($volume->is_too_large)->toBeFalse();
|
||||
});
|
||||
|
||||
it('exposes the too-large flag via toArray for Livewire serialization', function () {
|
||||
$volume = new LocalFileVolume;
|
||||
$volume->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
|
||||
|
||||
$array = $volume->toArray();
|
||||
|
||||
expect($array)->toHaveKey('is_too_large');
|
||||
expect($array['is_too_large'])->toBeTrue();
|
||||
});
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
<?php
|
||||
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Jobs\ServerCheckJob;
|
||||
use App\Jobs\ServerConnectionCheckJob;
|
||||
use App\Jobs\ServerManagerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Queue\TimeoutExceededException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
|
@ -126,16 +128,21 @@
|
|||
|
||||
describe('ServerConnectionCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
Event::fake([ServerReachabilityChanged::class]);
|
||||
|
||||
$settings = Mockery::mock();
|
||||
$settings->is_reachable = true;
|
||||
$settings->shouldReceive('update')
|
||||
->with(['is_reachable' => false, 'is_usable' => false])
|
||||
->once();
|
||||
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_notification_sent')->andReturn(false);
|
||||
$server->shouldReceive('increment')->with('unreachable_count')->once();
|
||||
$server->id = 1;
|
||||
$server->name = 'test-server';
|
||||
$server->unreachable_count = 1; // Will become 2 after increment in real code; mock keeps value as-is
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$job->failed(new TimeoutExceededException);
|
||||
|
|
@ -152,6 +159,50 @@
|
|||
});
|
||||
});
|
||||
|
||||
describe('ServerConnectionCheckJob ServerReachabilityChanged dispatch', function () {
|
||||
// ServerReachabilityChanged's constructor calls $server->isReachableChanged() — verifying that
|
||||
// call is a clean proxy for "the event was dispatched", and avoids serializing a Mockery proxy
|
||||
// through the event dispatcher (which trips Eloquent static method lookups on the proxy class).
|
||||
$invoke = function (bool $wasReachable, bool $wasNotified, bool $isReachable, int $unreachableCount, bool $expectDispatch) {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
$server->shouldReceive('getAttribute')->with('unreachable_count')->andReturn($unreachableCount);
|
||||
$server->shouldReceive('getAttribute')->with('id')->andReturn(1);
|
||||
if ($expectDispatch) {
|
||||
$server->shouldReceive('isReachableChanged')->once()->andReturnNull();
|
||||
} else {
|
||||
$server->shouldNotReceive('isReachableChanged');
|
||||
}
|
||||
|
||||
$job = new ServerConnectionCheckJob($server);
|
||||
$method = new ReflectionMethod($job, 'dispatchReachabilityChangedIfNeeded');
|
||||
$method->invoke($job, $wasReachable, $wasNotified, $isReachable);
|
||||
};
|
||||
|
||||
it('dispatches event when count crosses unreachable threshold', function () use ($invoke) {
|
||||
$invoke(true, false, false, 2, true);
|
||||
});
|
||||
|
||||
it('does not dispatch on first transient failure (count=1)', function () use ($invoke) {
|
||||
$invoke(true, false, false, 1, false);
|
||||
});
|
||||
|
||||
it('does not dispatch when already notified and still unreachable', function () use ($invoke) {
|
||||
$invoke(false, true, false, 5, false);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously unreachable', function () use ($invoke) {
|
||||
$invoke(false, false, true, 0, true);
|
||||
});
|
||||
|
||||
it('dispatches recovery event when previously notified', function () use ($invoke) {
|
||||
$invoke(true, true, true, 0, true);
|
||||
});
|
||||
|
||||
it('does not dispatch when consistently reachable and never notified', function () use ($invoke) {
|
||||
$invoke(true, false, true, 0, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServerCheckJob unreachable_count', function () {
|
||||
it('increments unreachable_count on timeout', function () {
|
||||
$server = Mockery::mock(Server::class)->makePartial()->shouldAllowMockingProtectedMethods();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"coolify": {
|
||||
"v4": {
|
||||
"version": "4.0.0"
|
||||
"version": "4.1.0"
|
||||
},
|
||||
"nightly": {
|
||||
"version": "4.0.0"
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
"version": "1.0.13"
|
||||
},
|
||||
"realtime": {
|
||||
"version": "1.0.13"
|
||||
"version": "1.0.14"
|
||||
},
|
||||
"sentinel": {
|
||||
"version": "0.0.21"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import vue from "@vitejs/plugin-vue";
|
|||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const viteHost = env.VITE_HOST || null;
|
||||
const vitePort = Number(env.VITE_PORT || 5173);
|
||||
|
||||
return {
|
||||
server: {
|
||||
|
|
@ -14,9 +16,20 @@ export default defineConfig(({ mode }) => {
|
|||
],
|
||||
},
|
||||
host: "0.0.0.0",
|
||||
hmr: {
|
||||
host: env.VITE_HOST || '0.0.0.0'
|
||||
allowedHosts: true,
|
||||
cors: {
|
||||
origin: [
|
||||
/^https?:\/\/localhost(:\d+)?$/,
|
||||
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
||||
/^https?:\/\/\[::1\](:\d+)?$/,
|
||||
...(env.APP_URL ? [env.APP_URL] : []),
|
||||
...(viteHost ? [`http://${viteHost}:${vitePort}`, `https://${viteHost}:${vitePort}`] : []),
|
||||
],
|
||||
},
|
||||
origin: viteHost ? `http://${viteHost}:${vitePort}` : undefined,
|
||||
hmr: viteHost
|
||||
? { host: viteHost, clientPort: vitePort }
|
||||
: true,
|
||||
},
|
||||
plugins: [
|
||||
laravel({
|
||||
|
|
|
|||
Loading…
Reference in a new issue