Merge remote-tracking branch 'origin/main' into feat/services

This commit is contained in:
peaklabs-dev committed 2026-08-24 19:48:56 +02:00
commit 145c0f6672
249 files changed
+8928 -1027

No files matched your search

+152
View File
@@ -0,0 +1,152 @@
name: Build Coolify Next
on:
push:
branches: [next]
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
concurrency:
group: coolify-next-build
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
rc_version: ${{ steps.version.outputs.rc_version }}
short_sha: ${{ steps.version.outputs.short_sha }}
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Resolve next version
id: version
run: |
RC_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ ! "${RC_VERSION}" =~ ^[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Invalid next RC version: ${RC_VERSION}"
exit 1
fi
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${RC_VERSION}.${SHORT_SHA}"
echo "rc_version=${RC_VERSION}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
build:
needs: prepare
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push next image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.prepare.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:next-build-${{ needs.prepare.outputs.short_sha }}-${{ matrix.arch }}
publish:
needs: [prepare, build]
runs-on: ubuntu-24.04
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish next manifest on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish next manifest on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
SHA: ${{ needs.prepare.outputs.short_sha }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="next-build-${SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:sha-${SHA}" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
+304
View File
@@ -0,0 +1,304 @@
name: Release Coolify RC
run-name: ${{ inputs.tag }}
on:
workflow_dispatch:
inputs:
tag:
description: Existing draft prerelease tag (for example, v4.4-rc.1)
required: true
type: string
permissions: {}
concurrency:
group: coolify-rc-release
cancel-in-progress: false
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: coollabsio/coolify
jobs:
validate:
runs-on: ubuntu-24.04
permissions:
contents: write
outputs:
release_id: ${{ steps.draft.outputs.release_id }}
version: ${{ steps.version.outputs.version }}
steps:
- name: Reject releases outside next
if: ${{ github.ref != 'refs/heads/next' }}
run: |
echo "RC releases must run from the next branch, not ${{ github.ref }}."
exit 1
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version
id: version
env:
TAG_NAME: ${{ inputs.tag }}
run: |
if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
echo "Unsupported RC tag: ${TAG_NAME}"
exit 1
fi
VERSION="${TAG_NAME#v}"
CONFIG_VERSION=$(jq -r '.coolify.nightly.version' versions.json)
if [[ "${CONFIG_VERSION}" != "${VERSION}" ]]; then
echo "RC tag ${VERSION} does not match nightly version ${CONFIG_VERSION}."
exit 1
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
- name: Validate and pin draft prerelease
id: draft
uses: actions/github-script@v8
env:
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releases = await github.paginate(github.rest.repos.listReleases, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const release = releases.find((candidate) => candidate.tag_name === process.env.TAG_NAME);
if (!release) {
core.setFailed(`Create a draft prerelease for ${process.env.TAG_NAME} before running this workflow.`);
return;
}
if (!release.draft) {
core.setFailed(`Release ${process.env.TAG_NAME} must still be a draft.`);
return;
}
if (!release.prerelease) {
core.setFailed(`RC release ${process.env.TAG_NAME} must be marked as a prerelease.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} must contain reviewed release notes.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} already exists.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
});
core.setOutput('release_id', release.id);
build:
needs: validate
permissions:
contents: read
packages: write
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push RC image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
COOLIFY_VERSION=${{ needs.validate.outputs.version }}
tags: |
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:rc-release-${{ needs.validate.outputs.version }}-${{ github.sha }}-${{ matrix.arch }}
revalidate:
needs: [validate, build]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Revalidate draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
} catch (error) {
if (error.status !== 404) throw error;
}
publish:
needs: [validate, build, revalidate]
runs-on: ubuntu-24.04
permissions:
contents: write
packages: write
steps:
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Publish RC and next on ${{ env.GITHUB_REGISTRY }}
env:
REGISTRY: ${{ env.GITHUB_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish RC and next on ${{ env.DOCKER_REGISTRY }}
env:
REGISTRY: ${{ env.DOCKER_REGISTRY }}
VERSION: ${{ needs.validate.outputs.version }}
run: |
IMAGE="${REGISTRY}/${IMAGE_NAME}"
SOURCE="rc-release-${VERSION}-${GITHUB_SHA}"
docker buildx imagetools create \
"${IMAGE}:${SOURCE}-amd64" \
"${IMAGE}:${SOURCE}-aarch64" \
--tag "${IMAGE}:${VERSION}" \
--tag "${IMAGE}:next"
- name: Publish reviewed draft prerelease
uses: actions/github-script@v8
env:
RELEASE_ID: ${{ needs.validate.outputs.release_id }}
TAG_NAME: ${{ inputs.tag }}
with:
script: |
const releaseId = Number(process.env.RELEASE_ID);
const { data: release } = await github.rest.repos.getRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
});
if (release.tag_name !== process.env.TAG_NAME || !release.draft || !release.prerelease) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} changed while the images were building.`);
return;
}
if (!release.body?.trim()) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer contains release notes.`);
return;
}
if (release.target_commitish !== context.sha) {
core.setFailed(`Draft prerelease ${process.env.TAG_NAME} no longer targets ${context.sha}.`);
return;
}
try {
await github.rest.git.getRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `tags/${process.env.TAG_NAME}`,
});
core.setFailed(`Git tag ${process.env.TAG_NAME} was created while the images were building.`);
return;
} catch (error) {
if (error.status !== 404) throw error;
}
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: Number(process.env.RELEASE_ID),
tag_name: process.env.TAG_NAME,
target_commitish: context.sha,
prerelease: true,
draft: false,
});
-134
View File
@@ -1,134 +0,0 @@
name: Staging Build
on:
push:
branches-ignore:
- main
- v3.x
- '**v5.x**'
paths-ignore:
- .github/workflows/coolify-helper.yml
- .github/workflows/coolify-helper-next.yml
- .github/workflows/coolify-realtime.yml
- .github/workflows/coolify-realtime-next.yml
- .github/workflows/pr-quality.yaml
- docker/coolify-helper/Dockerfile
- docker/coolify-realtime/Dockerfile
- docker/testing-host/Dockerfile
- templates/**
- CHANGELOG.md
permissions:
contents: read
packages: write
env:
GITHUB_REGISTRY: ghcr.io
DOCKER_REGISTRY: docker.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
build-push:
strategy:
matrix:
include:
- arch: amd64
platform: linux/amd64
runner: ubuntu-24.04
- arch: aarch64
platform: linux/aarch64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Image (${{ matrix.arch }})
uses: docker/build-push-action@v6
with:
context: .
file: docker/production/Dockerfile
platforms: ${{ matrix.platform }}
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-${{ matrix.arch }}
cache-from: |
type=gha,scope=build-${{ matrix.arch }}
type=registry,ref=${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=build-${{ matrix.arch }}
merge-manifest:
runs-on: ubuntu-24.04
needs: build-push
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.GITHUB_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to ${{ env.DOCKER_REGISTRY }}
uses: docker/login-action@v3
with:
registry: ${{ env.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-amd64 \
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}
+62
View File
@@ -0,0 +1,62 @@
name: Sync main to next
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: sync-main-to-next
cancel-in-progress: false
jobs:
sync:
name: Merge main into next
runs-on: ubuntu-latest
steps:
- name: Checkout next
uses: actions/checkout@v5
with:
ref: next
fetch-depth: 0
- name: Merge main into next
env:
GH_TOKEN: ${{ github.token }}
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git fetch origin main next
if git merge --no-edit origin/main; then
git push origin HEAD:next
exit 0
fi
conflicts=$(git diff --name-only --diff-filter=U)
git merge --abort
if [ -z "$conflicts" ]; then
echo 'The merge failed without conflicts, so no pull request was created.'
exit 1
fi
sync_branch='automation/sync-main-to-next'
existing_pr=$(gh pr list --base next --head "$sync_branch" --state open --json url --jq '.[0].url')
if [ -n "$existing_pr" ]; then
echo "A main to next pull request already exists: $existing_pr"
else
git push --force origin origin/main:"refs/heads/$sync_branch"
gh pr create \
--base next \
--head "$sync_branch" \
--title 'chore: merge main into next' \
--body 'This pull request was created automatically because main could not be merged into next without conflicts. Resolve conflicts on this temporary branch; never update main with next.'
fi
echo 'main could not be merged into next without conflicts.'
exit 1
+1
View File
@@ -39,6 +39,7 @@ docker/coolify-realtime/node_modules
CHANGELOG.md CHANGELOG.md
/.workspaces /.workspaces
/.superpowers/ /.superpowers/
/docs/superpowers/plans/
tests/Browser/Screenshots tests/Browser/Screenshots
tests/v4/Browser/Screenshots tests/v4/Browser/Screenshots
ref ref
+22 -1
View File
@@ -99,9 +99,30 @@ function loginAsRoot(): mixed
``` ```
- See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions. - See `tests/v4/Browser/LoginTest.php`, `tests/v4/Browser/DashboardTest.php`, and `tests/v4/Browser/RegistrationTest.php` for conventions.
- Chrome driver runs on `localhost:4444`, app on `localhost:8000` (configured in `tests/DuskTestCase.php`).
- Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API. - Legacy Dusk macros in `app/Providers/DuskServiceProvider.php` use the old `type()`/`press()` API — do not mix with Pest Browser Plugin's `fill()`/`click()` API.
### How Browser Tests Actually Run (no Docker, no display needed)
`visit()` does NOT hit the dev app on `localhost:8000` and does NOT use the Dusk ChromeDriver on `:4444` (that config in `tests/DuskTestCase.php` is legacy). Instead the Pest Browser Plugin:
1. Starts a local Playwright server (`node node_modules/.bin/playwright run-server`) and launches a **headless Chromium** from `~/.cache/ms-playwright` (install once with `npm install && npx playwright install chromium`).
2. Boots an **in-process amphp HTTP server** on a random port that serves the Laravel app from the test process itself.
Because the "server" and the test share one PHP process, they share the phpunit env (sqlite `:memory:`, array cache) — so `config()->set(...)`, model writes, and `Cache` calls in the test are visible to browser-issued requests, and `RefreshDatabase` never touches the dev Postgres.
`->screenshot(filename: '...')` writes real PNGs to `tests/Browser/Screenshots/` — read them to visually verify UI state (toasts, modals, stray elements).
### Browser Test Gotchas
- **`Class "Redis" not found` thrown by the HTTP server**: host PHP has no phpredis, and the maintenance-mode store is hard-wired to redis (`config/app.php` → `'maintenance' => ['store' => 'redis']`). Add `config()->set('app.maintenance.store', 'array');` in `beforeEach`.
- **Every path redirects to onboarding** for a fresh user (`DecideWhatToDoWithUser` + `showBoarding()`). Finish boarding before navigating: `Team::query()->update(['show_boarding' => false]); Cache::flush();` — the `Cache::flush()` is required because `User::currentTeam()` caches the Team for an hour and the in-process server shares that cache.
- **`->navigate('/path')` races form-submit redirects.** After `->click('Login')`, assert something on the destination page (e.g. `->assertSee('Welcome to Coolify')`) before calling `navigate()`.
- **Failure messages print the *initial* `visit()` URL**, not the current URL. Read the auto-saved screenshot in `tests/Browser/Screenshots/` to see where the browser actually ended up.
- **Runs hang forever**: stale Playwright servers from a previously killed run. Fix: `pkill -f "playwright run-server"` and rerun. Healthy runs take seconds.
- **Guest pages miss `DOMPurify`** (`public/js/purify.min.js` loads only `@auth` in `layouts/base.blade.php`), so toast descriptions fail on unauthenticated pages — log in first for toast-related assertions.
- Layouts that call `@livewireScripts` manually must also call `@livewireStyles`, otherwise Livewire's asset auto-injection is disabled and `[wire\:loading]`/`[x-cloak]` elements render visible.
- Run browser test files in their own `php artisan test` invocation — combining them with non-browser test paths in one command can hang the runner.
## Architecture ## Architecture
### Backend Structure (app/) ### Backend Structure (app/)
+29
View File
@@ -228,6 +228,35 @@ A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effor
## Local Development ## Local Development
To build and run Coolify locally, see: [Development](./DEVELOPMENT.md) To build and run Coolify locally, see: [Development](./DEVELOPMENT.md)
### Testing the Coolify Helper Locally
Use `scripts/dev-helper` to build a local helper image and test it with the running development instance. The script requires the standard local Coolify container and the seeded Dockerfile, Docker Compose, and Nixpacks applications.
Run the complete workflow:
```bash
./scripts/dev-helper test my-helper-test
```
This builds and selects the helper image, verifies its bundled tools and Docker socket access, runs a Docker Compose smoke test, and deploys all three seeded applications.
You can also run each step separately:
```bash
./scripts/dev-helper build my-helper-test
./scripts/dev-helper use my-helper-test
./scripts/dev-helper verify my-helper-test
./scripts/dev-helper deploy my-helper-test
```
Clear the helper override when finished:
```bash
./scripts/dev-helper reset
```
The default image repository is `docker.io/coollabsio/coolify-helper`. Set `HELPER_IMAGE_REPOSITORY` to test another repository, or `COOLIFY_CONTAINER` if the local Coolify container has a different name.
### macOS Development with Lima ### macOS Development with Lima
Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS. Mac users can use [Lima](https://lima-vm.io/) to run a lightweight Linux virtual machine for local Coolify development. This is useful if you prefer a Linux-based Docker environment on macOS.
+6 -5
View File
@@ -9,7 +9,7 @@
| `feature/*` | New features based on and merged into `next` | | `feature/*` | New features based on and merged into `next` |
| `hotfix/X.Y.Z` | Production fixes based on `main` | | `hotfix/X.Y.Z` | Production fixes based on `main` |
Release workflows never edit or commit versions. Set the intended version in `config/constants.php` before running a release workflow. Release workflows never edit or commit versions. Stable versions come from `config/constants.php`; RC versions come from `coolify.nightly.version` in `versions.json` and `other/nightly/versions.json`.
## Where changes go ## Where changes go
@@ -25,11 +25,12 @@ feature/* → next → RC
``` ```
1. Merge feature branches into `next`. 1. Merge feature branches into `next`.
2. Set the intended RC version on `next`, such as `4.4-rc.1`. 2. Set `coolify.nightly.version` in both version files to the intended RC, such as `4.4-rc.1`.
3. Regular builds publish `sha-<commit>`, `4.4-rc.1.<short-sha>`, and the moving `next` tag. 3. Regular `next` builds publish `sha-<short-sha>`, `4.4-rc.1.<short-sha>`, and the moving `next` tag. They never publish the exact `4.4-rc.1` tag.
4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease. 4. Create a reviewed draft GitHub Release named `v4.4-rc.1` and mark it as a prerelease.
5. Run the RC workflow from `next`. It publishes `4.4-rc.1`, updates `next`, and publishes the draft. 5. Run **Release Coolify RC** manually from `next` and enter `v4.4-rc.1`.
6. Advance `next` to the next intended RC version. 6. The workflow validates the draft and configured nightly version, builds the exact RC, publishes `4.4-rc.1`, updates `next`, and publishes the draft prerelease.
7. Advance `coolify.nightly.version` to the next intended RC version.
## Stable release flow ## Stable release flow
@@ -29,7 +29,7 @@ class StopApplicationOneServer
instant_remote_process( instant_remote_process(
[ [
dockerStopCommand($timeout, $containerName, $server), dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName", dockerRemoveCommand($containerName),
], ],
$server $server
); );
+2 -2
View File
@@ -24,10 +24,10 @@ class StopDatabaseProxy
{ {
$server = data_get($database, 'destination.server'); $server = data_get($database, 'destination.server');
$uuid = $database->uuid; $uuid = $database->uuid;
if ($database->getMorphClass() === \App\Models\ServiceDatabase::class) { if ($database->getMorphClass() === ServiceDatabase::class) {
$server = data_get($database, 'service.server'); $server = data_get($database, 'service.server');
} }
instant_remote_process(["docker rm -f {$uuid}-proxy"], $server); instant_remote_process([dockerRemoveCommand("{$uuid}-proxy")], $server);
$database->save(); $database->save();
@@ -11,6 +11,6 @@ class RemoveStandaloneDockerNetwork
$safeNetwork = escapeshellarg($destination->network); $safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server); instant_remote_process([dockerNetworkRemoveCommand($destination->network)], $destination->server);
} }
} }
@@ -0,0 +1,122 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class ConfigureDevelopmentQemuHost
{
use AsAction;
public function handle(): void
{
$this->ensureDevelopmentEnvironment();
$this->installDependencies();
$this->runOrFail('systemctl enable --now libvirtd');
$this->configureLibvirtNetwork();
$this->configureIpForwarding();
$this->configureStorage();
$this->configureDockerForwarding();
}
private function installDependencies(): void
{
$binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install'];
$check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && ');
if (Process::run($check)->successful()) {
return;
}
if (! File::exists('/usr/bin/apt-get')) {
throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.');
}
$this->runOrFail('apt-get update');
$this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst');
}
private function configureLibvirtNetwork(): void
{
$network = config('development-qemu.libvirt_network');
$networkInfo = Process::run('virsh net-info '.escapeshellarg($network));
if ($networkInfo->failed()) {
$networkXml = config('development-qemu.storage_path').'/libvirt-network.xml';
File::ensureDirectoryExists(dirname($networkXml), 0777, true);
File::put($networkXml, $this->libvirtNetworkXml($network));
$this->runOrFail('virsh net-define '.escapeshellarg($networkXml));
$networkInfo = Process::result(output: 'Active: no');
}
if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) {
$this->runOrFail('virsh net-start '.escapeshellarg($network));
}
$this->runOrFail('virsh net-autostart '.escapeshellarg($network));
}
private function configureIpForwarding(): void
{
$this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf");
$this->runOrFail('sysctl -w net.ipv4.ip_forward=1');
}
private function configureStorage(): void
{
$directory = config('development-qemu.storage_path');
File::ensureDirectoryExists($directory, 0777, true);
File::chmod($directory, 0777);
}
private function configureDockerForwarding(): void
{
$dockerNetwork = escapeshellarg(config('development-qemu.docker_network'));
$subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}'));
$subnet = trim($subnetResult->output());
if ($subnetResult->failed() || $subnet === '') {
throw new RuntimeException('Unable to determine the Coolify Docker network subnet.');
}
$rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet')));
Process::run("iptables -D LIBVIRT_FWI {$rule}");
$this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}");
}
private function libvirtNetworkXml(string $network): string
{
return <<<XML
<network>
<name>{$network}</name>
<forward mode="nat"/>
<bridge name="virbr0" stp="on" delay="0"/>
<ip address="192.168.122.1" netmask="255.255.255.0">
<dhcp>
<range start="192.168.122.2" end="192.168.122.254"/>
</dhcp>
</ip>
</network>
XML;
}
private function runOrFail(string $command): void
{
$result = Process::forever()->run($command);
if ($result->failed()) {
throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}");
}
}
private function ensureDevelopmentEnvironment(): void
{
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
throw new RuntimeException('QEMU host configuration may only run in development environments.');
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Development;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class ManageDevelopmentQemuVm
{
use AsAction;
/** @param string|array<int, string> $profileNames */
public function handle(string|array $profileNames): void
{
$profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames];
foreach ($profileNames as $index => $profileName) {
StartDevelopmentQemuVm::run($profileName, $index === 0);
try {
SeedDevelopmentQemuServer::run($profileName, $index === 0);
} catch (QueryException $exception) {
$keepOthers = $index === 0 ? '' : ' --keep-others';
$result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers);
if ($result->failed()) {
throw $exception;
}
}
}
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Development;
use App\Models\PrivateKey;
use App\Models\Server;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class SeedDevelopmentQemuServer
{
use AsAction;
public function handle(string $profileName, bool $removeOtherServers = true): Server
{
$this->ensureDevelopmentEnvironment();
$profile = config("development-qemu.profiles.{$profileName}");
if (! is_array($profile)) {
throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}");
}
$privateKey = PrivateKey::query()->find(1);
if (! $privateKey) {
throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.');
}
if ($removeOtherServers) {
Server::query()
->where('uuid', 'like', 'development-qemu-%')
->where('uuid', '!=', $profile['uuid'])
->delete();
}
$server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server;
$server->forceFill(['uuid' => $profile['uuid']]);
$server->fill([
'name' => $profile['name'],
'description' => 'Development-only QEMU virtual machine managed by dev:qemu.',
'ip' => $profile['ip'],
'port' => 22,
'user' => $profile['user'],
'team_id' => 0,
'private_key_id' => $privateKey->id,
]);
$server->deleted_at = null;
$server->save();
return $server->fresh();
}
private function ensureDevelopmentEnvironment(): void
{
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
throw new RuntimeException('QEMU VM servers may only be seeded in development environments.');
}
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class StartDevelopmentQemuVm
{
use AsAction;
public function handle(string $profileName, bool $resetManagedVms = true): void
{
$this->ensureDevelopmentEnvironment();
$profiles = config('development-qemu.profiles');
$profile = $profiles[$profileName] ?? null;
if (! is_array($profile)) {
throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}");
}
ConfigureDevelopmentQemuHost::run();
$this->configureDhcpReservation($profile);
if ($resetManagedVms) {
foreach ($profiles as $managedProfile) {
Process::run('virsh destroy '.escapeshellarg($managedProfile['domain']));
Process::run('virsh undefine '.escapeshellarg($managedProfile['domain']));
$this->deleteVmData($managedProfile['domain']);
}
}
$this->createVm($profile);
ConfigureDevelopmentQemuHost::run();
$this->waitForSsh($profile['ip']);
}
/** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */
private function createVm(array $profile): void
{
$directory = config('development-qemu.storage_path');
File::ensureDirectoryExists($directory);
File::chmod($directory, 0777);
$this->moveLegacyFiles($directory);
$baseImage = "{$directory}/{$profile['image']}";
$disk = "{$directory}/{$profile['domain']}.qcow2";
$userData = "{$directory}/{$profile['domain']}-user-data.yaml";
$networkConfig = "{$directory}/{$profile['domain']}-network.yaml";
if (! File::exists($baseImage)) {
$this->runOrFail(sprintf(
'curl --fail --location --output %s %s',
escapeshellarg($baseImage),
escapeshellarg($profile['image_url']),
));
}
if (! File::exists($disk)) {
$this->runOrFail(sprintf(
'qemu-img create -f qcow2 -F qcow2 -b %s %s %s',
escapeshellarg($baseImage),
escapeshellarg($disk),
escapeshellarg(config('development-qemu.disk_size')),
));
}
if (File::exists($baseImage)) {
File::chmod($baseImage, 0644);
}
if (File::exists($disk)) {
File::chmod($disk, 0666);
}
File::put($userData, $this->userData($profile));
File::put($networkConfig, $this->networkConfig($profile));
$this->runOrFail(sprintf(
'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole',
escapeshellarg($profile['domain']),
config('development-qemu.memory'),
config('development-qemu.vcpus'),
escapeshellarg($profile['os_variant']),
escapeshellarg($disk),
escapeshellarg(config('development-qemu.libvirt_network')),
escapeshellarg($profile['mac']),
escapeshellarg($userData),
escapeshellarg($networkConfig),
));
}
private function moveLegacyFiles(string $directory): void
{
$legacyDirectory = storage_path('app/development-qemu');
if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) {
return;
}
foreach (File::files($legacyDirectory) as $file) {
$destination = "{$directory}/{$file->getFilename()}";
if (! File::exists($destination)) {
File::move($file->getPathname(), $destination);
}
}
}
private function deleteVmData(string $domain): void
{
$directory = config('development-qemu.storage_path');
File::delete([
"{$directory}/{$domain}.qcow2",
"{$directory}/{$domain}-user-data.yaml",
"{$directory}/{$domain}-network.yaml",
]);
}
/** @param array{user: string, provisioner: string} $profile */
private function userData(array $profile): string
{
$publicKey = config('development-qemu.public_key');
$adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel';
$sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n";
[$packages, $startDocker] = match ($profile['provisioner']) {
'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'],
'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'],
default => [" - docker.io\n - sudo", 'systemctl enable --now docker'],
};
$addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}";
return <<<YAML
#cloud-config
disable_root: false
users:
- name: {$profile['user']}
{$sudo} shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- {$publicKey}
package_update: true
packages:
{$packages}
runcmd:
- {$startDocker}{$addUserToDockerGroup}
YAML;
}
/** @param array{mac: string} $profile */
private function networkConfig(array $profile): string
{
return <<<YAML
version: 2
ethernets:
default:
match:
macaddress: "{$profile['mac']}"
dhcp4: true
YAML;
}
/** @param array{domain: string, ip: string, mac: string} $profile */
private function configureDhcpReservation(array $profile): void
{
$network = escapeshellarg(config('development-qemu.libvirt_network'));
$networkXml = Process::run("virsh net-dumpxml {$network}");
if ($networkXml->failed()) {
throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.');
}
if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) {
return;
}
$host = sprintf("<host mac='%s' name='%s' ip='%s'/>", $profile['mac'], $profile['domain'], $profile['ip']);
$this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config');
}
private function waitForSsh(string $ip): void
{
$container = escapeshellarg(config('development-qemu.coolify_container'));
$probe = <<<'PHP'
$deadline = time() + 120;
do {
$socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1);
if (is_resource($socket)) {
fclose($socket);
exit(0);
}
sleep(1);
} while (time() < $deadline);
exit(1);
PHP;
$this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip));
}
private function runOrFail(string $command): void
{
$result = Process::forever()->run($command);
if ($result->failed()) {
throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}");
}
}
private function ensureDevelopmentEnvironment(): void
{
if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) {
throw new RuntimeException('QEMU VMs may only be managed in development environments.');
}
}
}
+9 -1
View File
@@ -13,6 +13,8 @@ class GetProxyConfiguration
{ {
use AsAction; use AsAction;
public const MAX_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public function handle(Server $server, bool $forceRegenerate = false): string public function handle(Server $server, bool $forceRegenerate = false): string
{ {
$proxyType = $server->proxyType(); $proxyType = $server->proxyType();
@@ -98,11 +100,17 @@ class GetProxyConfiguration
private function backfillFromDisk(Server $server): ?string private function backfillFromDisk(Server $server): ?string
{ {
$proxy_path = $server->proxyPath(); $proxy_path = $server->proxyPath();
$configurationPath = escapeshellarg("$proxy_path/docker-compose.yml");
$readLimit = self::MAX_CONFIGURATION_SIZE_BYTES + 1;
$result = instant_remote_process([ $result = instant_remote_process([
"mkdir -p $proxy_path", "mkdir -p $proxy_path",
"cat $proxy_path/docker-compose.yml 2>/dev/null", "if [ ! -f {$configurationPath} ]; then exit 0; elif [ \"$(wc -c < {$configurationPath})\" -gt ".self::MAX_CONFIGURATION_SIZE_BYTES." ]; then echo '__COOLIFY_PROXY_CONFIG_TOO_LARGE__'; else head -c {$readLimit} {$configurationPath}; fi",
], $server, false); ], $server, false);
if ($result === '__COOLIFY_PROXY_CONFIG_TOO_LARGE__' || strlen($result ?? '') > self::MAX_CONFIGURATION_SIZE_BYTES) {
throw new \RuntimeException('Proxy configuration exceeds the 5 MiB size limit.');
}
if (! empty(trim($result ?? ''))) { if (! empty(trim($result ?? ''))) {
$server->proxy->last_saved_proxy_configuration = $result; $server->proxy->last_saved_proxy_configuration = $result;
$server->save(); $server->save();
+35 -60
View File
@@ -2,77 +2,52 @@
namespace App\Actions\Service; namespace App\Actions\Service;
use App\Actions\Server\CleanupDocker;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Log;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteService class DeleteService
{ {
use AsAction; public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void
public function handle(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations, bool $dockerCleanup)
{ {
try { $server = data_get($service, 'server');
$server = data_get($service, 'server'); if ($deleteVolumes && $server->isFunctional()) {
if ($deleteVolumes && $server->isFunctional()) { $commands = [];
$storagesToDelete = collect([]); foreach ($service->applications()->get() as $application) {
foreach ($application->persistentStorages()->get() as $storage) {
$service->environment_variables()->delete();
$commands = [];
foreach ($service->applications()->get() as $application) {
$storages = $application->persistentStorages()->get();
foreach ($storages as $storage) {
$storagesToDelete->push($storage);
}
}
foreach ($service->databases()->get() as $database) {
$storages = $database->persistentStorages()->get();
foreach ($storages as $storage) {
$storagesToDelete->push($storage);
}
}
foreach ($storagesToDelete as $storage) {
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name); $commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
} }
// Execute volume deletion first, this must be done first otherwise volumes will not be deleted.
if (! empty($commands)) {
foreach ($commands as $command) {
$result = instant_remote_process([$command], $server, false);
if ($result !== null && $result !== 0) {
Log::error('Error deleting volumes: '.$result);
}
}
}
}
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
} finally {
if ($deleteConfigurations) {
$service->deleteConfigurations();
}
foreach ($service->applications()->get() as $application) {
$application->forceDelete();
} }
foreach ($service->databases()->get() as $database) { foreach ($service->databases()->get() as $database) {
$database->forceDelete(); foreach ($database->persistentStorages()->get() as $storage) {
$commands[] = 'docker volume rm -f '.escapeshellarg($storage->name);
}
} }
foreach ($service->scheduled_tasks as $task) { foreach ($commands as $command) {
$task->delete(); instant_remote_process([$command], $server, false);
}
$service->tags()->detach();
$service->forceDelete();
if ($dockerCleanup) {
CleanupDocker::dispatch($server, false, false);
} }
} }
if ($deleteConnectedNetworks) {
$service->deleteConnectedNetworks();
}
if ($deleteConfigurations) {
$service->deleteConfigurations();
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
}
public function deleteLocal(Service $service): void
{
foreach ($service->applications()->get() as $application) {
$application->forceDelete();
}
foreach ($service->databases()->get() as $database) {
$database->forceDelete();
}
foreach ($service->scheduled_tasks as $task) {
$task->delete();
}
$service->environment_variables()->delete();
$service->tags()->detach();
$service->forceDelete();
} }
} }
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace App\Actions\Shared;
use App\Models\Server;
use Lorisleiva\Actions\Concerns\AsAction;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
class CheckDomainDns
{
use AsAction;
/**
* @param array<string, string> $entries
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
*/
public function handle(
array $entries,
?Server $server,
?string $expectedIp,
bool $skipForMultipleServers = false,
int $timeoutSeconds = 5,
): array {
if (! data_get(instanceSettings(), 'is_dns_validation_enabled')) {
return $this->sameResultForAll($entries, 'skipped', 'DNS validation is disabled in instance settings.', $expectedIp);
}
if (! $server) {
return $this->sameResultForAll($entries, 'skipped', 'No server available for DNS validation.', null);
}
if ($skipForMultipleServers) {
return $this->sameResultForAll($entries, 'skipped', 'DNS check skipped for multi-server applications.', $expectedIp);
}
$deadline = hrtime(true) + ($timeoutSeconds * 1_000_000_000);
$dnsServers = str(data_get(instanceSettings(), 'custom_dns_servers'))
->explode(',')
->map(fn ($dnsServer) => trim((string) $dnsServer))
->filter()
->values();
$results = [];
foreach ($entries as $key => $url) {
$results[$key] = $this->check($url, $server, $expectedIp, $dnsServers->all(), $deadline);
}
return $results;
}
/**
* @param array<int, string> $dnsServers
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function check(string $url, Server $server, ?string $expectedIp, array $dnsServers, int $deadline): array
{
try {
$host = Url::fromString($url)->getHost();
} catch (\Throwable) {
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
}
if (str($host)->contains('sslip.io')) {
return $this->result('ok', 'DNS looks correct.', $expectedIp);
}
$type = dnsRecordTypeForIp($expectedIp) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
foreach ($dnsServers as $dnsServer) {
$remainingNanoseconds = $deadline - hrtime(true);
if ($remainingNanoseconds < 1_000_000_000) {
return $this->result('failed', 'Could not validate DNS for this domain.', $expectedIp);
}
try {
$query = app()->make(DNSQuery::class, [
'server' => $dnsServer,
'port' => 53,
'timeout' => min(5, (int) floor($remainingNanoseconds / 1_000_000_000)),
]);
$records = $query->query($host, $type);
if ($records === false || $query->hasError()) {
continue;
}
foreach ($records as $record) {
if ($record->getType() !== $type) {
continue;
}
if (isCloudflareIp($record->getData()) || ($expectedIp && $record->getData() === $expectedIp)) {
return $this->result('ok', $this->successMessage($server, $expectedIp), $expectedIp);
}
}
} catch (\Throwable) {
continue;
}
}
return $this->result('failed', dnsMismatchGuidanceMessage($expectedIp, $expectedIp), $expectedIp);
}
private function successMessage(Server $server, ?string $expectedIp): string
{
if (
filled($expectedIp)
&& filled($server->ip)
&& $server->ip !== $expectedIp
&& filter_var($server->ip, FILTER_VALIDATE_IP) === false
) {
return "DNS points to {$expectedIp} ({$server->ip}) (or Cloudflare).";
}
return $expectedIp ? "DNS points to {$expectedIp} (or Cloudflare)." : 'DNS looks correct.';
}
/**
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function result(string $status, string $message, ?string $expectedIp): array
{
return [
'status' => $status,
'message' => $message,
'expected_ip' => $expectedIp,
'checked_at' => now()->toIso8601String(),
];
}
/**
* @param array<string, string> $entries
* @return array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}>
*/
private function sameResultForAll(array $entries, string $status, string $message, ?string $expectedIp): array
{
$result = $this->result($status, $message, $expectedIp);
return array_fill_keys(array_keys($entries), $result);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Actions\Team;
use App\Models\Application;
use App\Models\Team;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class DeleteTeam
{
public function handle(Team $team, User $user): ?Team
{
$newTeam = DB::transaction(function () use ($team, $user): ?Team {
$team = Team::query()->lockForUpdate()->findOrFail($team->id);
$role = DB::table('team_user')
->where('team_id', $team->id)
->where('user_id', $user->id)
->lockForUpdate()
->value('role');
if ($role !== 'owner') {
throw new AuthorizationException('Only team owners can delete a team.');
}
$hasRunningApplications = Application::query()
->whereHas('environment.project', fn ($query) => $query->where('team_id', $team->id))
->lockForUpdate()
->get(['id', 'status'])
->contains(fn (Application $application): bool => $application->isRunning());
if ($hasRunningApplications) {
throw new RuntimeException('Stop all running applications before deleting this team.');
}
if ($team->servers()->lockForUpdate()->get(['servers.id'])->isNotEmpty()) {
throw new RuntimeException('Delete all team servers before deleting this team.');
}
if (! $team->isEmpty()) {
throw new RuntimeException('Delete all team resources before deleting this team.');
}
$team->members()
->where('users.id', '!=', $user->id)
->get()
->each(function (User $member) use ($team): void {
$member->teams()->detach($team);
DB::table('sessions')->where('user_id', $member->id)->delete();
});
$team->delete();
return $user->teams()->first();
});
Cache::forget("user:{$user->id}:team:{$team->id}");
return $newTeam;
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\ManageDevelopmentQemuVm;
use Illuminate\Console\Command;
use function Laravel\Prompts\multiselect;
class ManageDevelopmentQemuVmCommand extends Command
{
protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php}';
protected $description = 'Recreate selected development QEMU VMs and seed their Coolify servers';
public function handle(): int
{
if (! isDev()) {
$this->error('This command may only run in development mode.');
return self::FAILURE;
}
$profiles = config('development-qemu.profiles');
$profileNames = $this->argument('profiles') ?: multiselect(
label: 'Which QEMU servers should be started and seeded?',
options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(),
required: true,
);
ManageDevelopmentQemuVm::run($profileNames);
foreach ($profileNames as $profileName) {
$profile = $profiles[$profileName];
$this->info("Started and seeded {$profile['label']} at {$profile['ip']}.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\SeedDevelopmentQemuServer;
use Illuminate\Console\Command;
class SeedDevelopmentQemuServerCommand extends Command
{
protected $signature = 'dev:qemu:seed {profile : Profile key from config/development-qemu.php} {--keep-others}';
protected $description = 'Seed one development QEMU server in the Coolify database';
public function handle(): int
{
if (! isDev()) {
$this->error('This command may only run in development mode.');
return self::FAILURE;
}
$server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others'));
$this->info("Seeded {$server->name} at {$server->ip}.");
return self::SUCCESS;
}
}
+59 -41
View File
@@ -87,15 +87,48 @@ class SshMultiplexingHelper
return false; return false;
} }
self::storeConnectionMetadata($server);
return true; return true;
} }
public static function removeMuxFile(Server $server): void public static function removeMuxFile(Server $server): void
{ {
Process::run(self::muxControlCommand($server, 'exit')); $checkProcess = Process::run(self::muxControlCommand($server, 'check'));
self::clearConnectionMetadata($server); $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches)
? $matches[1]
: null;
if ($pid !== null) {
self::markMuxProcessAsRetiring($pid, self::muxSocket($server));
}
$stopProcess = Process::run(self::muxControlCommand($server, 'stop'));
if ($pid !== null && ! $stopProcess->successful()) {
self::unmarkMuxProcessAsRetiring($pid, self::muxSocket($server));
}
}
public static function markMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void
{
$processStartTime ??= self::processStartTime($pid);
Cache::forever(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime), true);
}
public static function isMuxProcessRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): bool
{
$processStartTime ??= self::processStartTime($pid);
$key = self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime);
if (! Cache::has($key)) {
return false;
}
return true;
}
public static function unmarkMuxProcessAsRetiring(string $pid, string $muxSocket, ?string $processStartTime = null): void
{
$processStartTime ??= self::processStartTime($pid);
Cache::forget(self::muxProcessRetirementKey($pid, $muxSocket, $processStartTime));
} }
public static function generateScpCommand(Server $server, string $source, string $dest): string public static function generateScpCommand(Server $server, string $source, string $dest): string
@@ -242,25 +275,6 @@ class SshMultiplexingHelper
return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok'); return $process->exitCode() === 0 && str_contains($process->output(), 'health_check_ok');
} }
public static function isConnectionExpired(Server $server): bool
{
$connectionAge = self::getConnectionAge($server);
$maxAge = config('constants.ssh.mux_max_age');
return $connectionAge !== null && $connectionAge > $maxAge;
}
public static function getConnectionAge(Server $server): ?int
{
$connectionTime = Cache::get("ssh_mux_connection_time_{$server->uuid}");
if ($connectionTime === null) {
return null;
}
return time() - $connectionTime;
}
public static function refreshMultiplexedConnection(Server $server): bool public static function refreshMultiplexedConnection(Server $server): bool
{ {
self::removeMuxFile($server); self::removeMuxFile($server);
@@ -273,6 +287,28 @@ class SshMultiplexingHelper
return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid; return 'ssh_mux_lock_'.(gethostname() ?: 'unknown').'_'.$server->uuid;
} }
private static function muxProcessRetirementKey(string $pid, string $muxSocket, ?string $processStartTime): string
{
return 'ssh_mux_retiring_'.hash('sha256', self::processScope().'|'.$pid.'|'.$processStartTime.'|'.$muxSocket);
}
private static function processScope(): string
{
return (gethostname() ?: 'unknown').'|'.(@readlink('/proc/self/ns/pid') ?: 'unknown');
}
private static function processStartTime(string $pid): ?string
{
$stat = @file_get_contents("/proc/{$pid}/stat");
if ($stat === false || ! preg_match('/^\d+ \(.*\) (.*)$/', trim($stat), $matches)) {
return null;
}
$fields = preg_split('/\s+/', $matches[1]);
return $fields[19] ?? null;
}
private static function masterConnectionExists(Server $server): bool private static function masterConnectionExists(Server $server): bool
{ {
return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0; return Process::run(self::muxControlCommand($server, 'check'))->exitCode() === 0;
@@ -284,14 +320,6 @@ class SshMultiplexingHelper
return false; return false;
} }
if (self::getConnectionAge($server) === null) {
self::storeConnectionMetadata($server);
}
if (self::isConnectionExpired($server)) {
return false;
}
if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) { if (config('constants.ssh.mux_health_check_enabled') && ! self::isConnectionHealthy($server)) {
return false; return false;
} }
@@ -382,14 +410,4 @@ class SshMultiplexingHelper
return $options.'-p '.escapeshellarg((string) $server->port).' '; return $options.'-p '.escapeshellarg((string) $server->port).' ';
} }
private static function storeConnectionMetadata(Server $server): void
{
Cache::put("ssh_mux_connection_time_{$server->uuid}", time(), config('constants.ssh.mux_persist_time') + 300);
}
private static function clearConnectionMetadata(Server $server): void
{
Cache::forget("ssh_mux_connection_time_{$server->uuid}");
}
} }
@@ -1604,7 +1604,12 @@ class ApplicationsController extends Controller
if ($return instanceof JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
$githubApp = GithubApp::whereTeamId($teamId)->where('uuid', $githubAppUuid)->first(); $githubApp = GithubApp::where('uuid', $githubAppUuid)
->where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
})
->first();
if (! $githubApp) { if (! $githubApp) {
return response()->json(['message' => 'Github App not found.'], 404); return response()->json(['message' => 'Github App not found.'], 404);
} }
@@ -15,9 +15,9 @@ use OpenApi\Attributes as OA;
class GithubController extends Controller class GithubController extends Controller
{ {
private function removeSensitiveData($githubApp) private function removeSensitiveData(GithubApp $githubApp, int $teamId)
{ {
if (request()->attributes->get('can_read_sensitive', false) === true) { if (request()->attributes->get('can_read_sensitive', false) === true && $githubApp->team_id === $teamId) {
$githubApp->makeVisible([ $githubApp->makeVisible([
'client_secret', 'client_secret',
'webhook_secret', 'webhook_secret',
@@ -97,8 +97,8 @@ class GithubController extends Controller
->orWhere('is_system_wide', true); ->orWhere('is_system_wide', true);
})->get(); })->get();
$githubApps = $githubApps->map(function ($app) { $githubApps = $githubApps->map(function ($app) use ($teamId) {
return $this->removeSensitiveData($app); return $this->removeSensitiveData($app, $teamId);
}); });
return response()->json($githubApps); return response()->json($githubApps);
@@ -642,7 +642,7 @@ class GithubController extends Controller
$rules['webhook_secret'] = 'string'; $rules['webhook_secret'] = 'string';
} }
if (isset($payload['private_key_uuid'])) { if (isset($payload['private_key_uuid'])) {
$rules['private_key_uuid'] = 'string|uuid'; $rules['private_key_uuid'] = 'string';
} }
if (! isCloud() && isset($payload['is_system_wide'])) { if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean'; $rules['is_system_wide'] = 'boolean';
@@ -13,9 +13,9 @@ use OpenApi\Attributes as OA;
class GitlabController extends Controller class GitlabController extends Controller
{ {
private function removeSensitiveData(GitlabApp $gitlabApp) private function removeSensitiveData(GitlabApp $gitlabApp, int $teamId)
{ {
if (request()->attributes->get('can_read_sensitive', false) === true) { if (request()->attributes->get('can_read_sensitive', false) === true && $gitlabApp->team_id === $teamId) {
$gitlabApp->makeVisible([ $gitlabApp->makeVisible([
'client_secret', 'client_secret',
'webhook_token', 'webhook_token',
@@ -108,8 +108,8 @@ class GitlabController extends Controller
->orWhere('is_system_wide', true); ->orWhere('is_system_wide', true);
})->get(); })->get();
$gitlabApps = $gitlabApps->map(function ($app) { $gitlabApps = $gitlabApps->map(function ($app) use ($teamId) {
return $this->removeSensitiveData($app); return $this->removeSensitiveData($app, $teamId);
}); });
return response()->json($gitlabApps); return response()->json($gitlabApps);
@@ -280,7 +280,7 @@ class GitlabController extends Controller
'gitlab_app_name' => $gitlabApp->name, 'gitlab_app_name' => $gitlabApp->name,
]); ]);
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201); return response()->json($this->removeSensitiveData($gitlabApp->fresh(), $teamId), 201);
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e); return handleError($e);
} }
@@ -441,7 +441,7 @@ class GitlabController extends Controller
return response()->json([ return response()->json([
'message' => 'GitLab app updated successfully', 'message' => 'GitLab app updated successfully',
'data' => $this->removeSensitiveData($gitlabApp->fresh()), 'data' => $this->removeSensitiveData($gitlabApp->fresh(), $teamId),
]); ]);
} catch (ModelNotFoundException $e) { } catch (ModelNotFoundException $e) {
return response()->json([ return response()->json([
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\InstanceSettings;
use App\Rules\ValidHostname;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use OpenApi\Attributes as OA;
class InstanceEmailSettingsController extends Controller
{
private const FIELDS = [
'smtp_enabled', 'smtp_from_address', 'smtp_from_name', 'smtp_host',
'smtp_port', 'smtp_encryption', 'smtp_username', 'smtp_password',
'smtp_timeout', 'smtp_ehlo_domain', 'resend_enabled', 'resend_api_key',
];
#[OA\Get(
summary: 'Get instance email settings',
description: 'Get instance-wide SMTP and Resend settings. Requires a root-team token belonging to a root-team admin or owner. Sensitive fields require the `read:sensitive` or `root` token ability.',
path: '/settings/email', operationId: 'get-instance-email-settings',
security: [['bearerAuth' => []]], tags: ['Settings'],
responses: [
new OA\Response(response: 200, description: 'Instance email settings.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Forbidden.'),
]
)]
public function show(): JsonResponse
{
$settings = InstanceSettings::get();
$this->authorizeRootTeam('view', $settings);
return response()->json($this->serialize($settings));
}
#[OA\Patch(
summary: 'Update instance email settings',
description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.',
path: '/settings/email', operationId: 'update-instance-email-settings',
security: [['bearerAuth' => []]], tags: ['Settings'],
responses: [
new OA\Response(response: 200, description: 'Updated instance email settings.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 403, description: 'Forbidden.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update(Request $request): JsonResponse
{
$settings = InstanceSettings::get();
$this->authorizeRootTeam('update', $settings);
$validator = customApiValidator($request->json()->all(), [
'smtp_enabled' => 'sometimes|boolean',
'smtp_from_address' => 'sometimes|nullable|email',
'smtp_from_name' => 'sometimes|nullable|string|max:255',
'smtp_host' => 'sometimes|nullable|string|max:255',
'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535',
'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none',
'smtp_username' => 'sometimes|nullable|string|max:255',
'smtp_password' => 'sometimes|nullable|string|max:255',
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
'resend_enabled' => 'sometimes|boolean',
'resend_api_key' => 'sometimes|nullable|string|max:255',
]);
if ($validator->fails()) {
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
}
$settings->fill($validator->validated());
$settings->save();
auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]);
return response()->json($this->serialize($settings->refresh()));
}
private function authorizeRootTeam(string $ability, InstanceSettings $settings): void
{
$teamId = getTeamIdFromToken();
abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.');
$this->authorize($ability, $settings);
}
private function serialize(InstanceSettings $settings): array
{
exposeSensitiveFields($settings);
return Arr::only($settings->toArray(), self::FIELDS);
}
}
@@ -11,6 +11,7 @@ use App\Models\Team;
use App\Models\TelegramNotificationSettings; use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings; use App\Models\WebhookNotificationSettings;
use App\Rules\SafeWebhookUrl; use App\Rules\SafeWebhookUrl;
use App\Rules\ValidHostname;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -37,6 +38,7 @@ class NotificationsController extends Controller
'smtp_username' => 'sometimes|nullable|string|max:255', 'smtp_username' => 'sometimes|nullable|string|max:255',
'smtp_password' => 'sometimes|nullable|string|max:255', 'smtp_password' => 'sometimes|nullable|string|max:255',
'smtp_timeout' => 'sometimes|nullable|integer|min:0', 'smtp_timeout' => 'sometimes|nullable|integer|min:0',
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
'resend_enabled' => 'sometimes|boolean', 'resend_enabled' => 'sometimes|boolean',
'resend_api_key' => 'sometimes|nullable|string|max:255', 'resend_api_key' => 'sometimes|nullable|string|max:255',
'use_instance_email_settings' => 'sometimes|boolean', 'use_instance_email_settings' => 'sometimes|boolean',
@@ -283,7 +285,7 @@ class NotificationsController extends Controller
#[OA\Get( #[OA\Get(
summary: 'Get email notification settings', summary: 'Get email notification settings',
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.', description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
path: '/notifications/email', path: '/notifications/email',
operationId: 'get-current-team-email-notifications', operationId: 'get-current-team-email-notifications',
security: [['bearerAuth' => []]], security: [['bearerAuth' => []]],
@@ -301,7 +303,7 @@ class NotificationsController extends Controller
#[OA\Patch( #[OA\Patch(
summary: 'Update email notification settings', summary: 'Update email notification settings',
description: 'Update the current team email notification settings.', description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.',
path: '/notifications/email', path: '/notifications/email',
operationId: 'update-current-team-email-notifications', operationId: 'update-current-team-email-notifications',
security: [['bearerAuth' => []]], security: [['bearerAuth' => []]],
@@ -158,6 +158,8 @@ class ProjectController extends Controller
if (! $project) { if (! $project) {
return response()->json(['message' => 'Project not found.'], 404); return response()->json(['message' => 'Project not found.'], 404);
} }
$this->authorize('view', $project);
$environment = $project->environments()->whereName($request->environment_name_or_uuid)->first(); $environment = $project->environments()->whereName($request->environment_name_or_uuid)->first();
if (! $environment) { if (! $environment) {
$environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first(); $environment = $project->environments()->whereUuid($request->environment_name_or_uuid)->first();
@@ -550,11 +550,7 @@ class ServersController extends Controller
} }
$foundServer = ModelsServer::whereIp($request->ip)->first(); $foundServer = ModelsServer::whereIp($request->ip)->first();
if ($foundServer) { if ($foundServer) {
if ($foundServer->team_id === $teamId) { return response()->json(['message' => 'A server with this IP/Domain is already in use.'], 400);
return response()->json(['message' => 'A server with this IP/Domain already exists in your team.'], 400);
}
return response()->json(['message' => 'A server with this IP/Domain is already in use by another team.'], 400);
} }
$proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value;
+9 -7
View File
@@ -56,7 +56,7 @@ class TeamController extends Controller
if (is_null($teamId)) { if (is_null($teamId)) {
return invalidTokenResponse(); return invalidTokenResponse();
} }
$teams = auth()->user()->teams->sortBy('id'); $teams = auth()->user()->teams->where('id', $teamId)->values();
$teams = $teams->map(function ($team) { $teams = $teams->map(function ($team) {
return $this->removeSensitiveData($team); return $this->removeSensitiveData($team);
}); });
@@ -100,13 +100,14 @@ class TeamController extends Controller
)] )]
public function team_by_id(Request $request) public function team_by_id(Request $request)
{ {
$id = $request->id;
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
return invalidTokenResponse(); return invalidTokenResponse();
} }
$teams = auth()->user()->teams; if ((int) $request->id !== (int) $teamId) {
$team = $teams->where('id', $id)->first(); return response()->json(['message' => 'Team not found.'], 404);
}
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) { if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404); return response()->json(['message' => 'Team not found.'], 404);
} }
@@ -159,13 +160,14 @@ class TeamController extends Controller
)] )]
public function members_by_id(Request $request) public function members_by_id(Request $request)
{ {
$id = $request->id;
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
return invalidTokenResponse(); return invalidTokenResponse();
} }
$teams = auth()->user()->teams; if ((int) $request->id !== (int) $teamId) {
$team = $teams->where('id', $id)->first(); return response()->json(['message' => 'Team not found.'], 404);
}
$team = auth()->user()->teams->where('id', $teamId)->first();
if (is_null($team)) { if (is_null($team)) {
return response()->json(['message' => 'Team not found.'], 404); return response()->json(['message' => 'Team not found.'], 404);
} }
@@ -34,7 +34,7 @@ use RuntimeException;
new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000), new OA\Property(property: 'retention_amount_s3', type: 'integer', default: 7, minimum: 0, maximum: 10000),
new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0), new OA\Property(property: 'retention_days_s3', type: 'integer', default: 0, maximum: 2147483647, minimum: 0),
new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0), new OA\Property(property: 'retention_max_storage_s3', type: 'number', format: 'float', default: 0, maximum: 9999999999, minimum: 0),
new OA\Property(property: 'timeout', type: 'integer', default: 3600, minimum: 60, maximum: 36000), new OA\Property(property: 'timeout', type: 'integer', default: ScheduledVolumeBackup::DEFAULT_TIMEOUT, minimum: 60, maximum: 36000),
], ],
type: 'object', type: 'object',
additionalProperties: false, additionalProperties: false,
@@ -261,7 +261,7 @@ class VolumeBackupsController extends Controller
string $resourceType, string $resourceType,
Model $resource, Model $resource,
): JsonResponse { ): JsonResponse {
$backup = $storage->scheduledBackups()->updateOrCreate([], [ $attributes = [
'team_id' => $teamId, 'team_id' => $teamId,
'frequency' => $request->string('frequency')->toString(), 'frequency' => $request->string('frequency')->toString(),
'enabled' => $request->boolean('enabled', true), 'enabled' => $request->boolean('enabled', true),
@@ -275,8 +275,12 @@ class VolumeBackupsController extends Controller
'retention_amount_s3' => $request->integer('retention_amount_s3', 7), 'retention_amount_s3' => $request->integer('retention_amount_s3', 7),
'retention_days_s3' => $request->integer('retention_days_s3'), 'retention_days_s3' => $request->integer('retention_days_s3'),
'retention_max_storage_s3' => $request->float('retention_max_storage_s3'), 'retention_max_storage_s3' => $request->float('retention_max_storage_s3'),
'timeout' => $request->integer('timeout', 3600), ];
]); if ($request->has('timeout')) {
$attributes['timeout'] = $request->integer('timeout');
}
$backup = $storage->scheduledBackups()->updateOrCreate([], $attributes);
$created = $backup->wasRecentlyCreated; $created = $backup->wasRecentlyCreated;
auditLog('api.volume_backup.schedule_set', [ auditLog('api.volume_backup.schedule_set', [
+99 -51
View File
@@ -8,12 +8,15 @@ use App\Models\User;
use App\Providers\RouteServiceProvider; use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password; use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@@ -95,61 +98,105 @@ class Controller extends BaseController
return response()->json(['message' => 'Transactional emails are not active'], 400); return response()->json(['message' => 'Transactional emails are not active'], 400);
} }
public function link() public function link(): View|RedirectResponse
{ {
$token = request()->get('token'); $token = request()->get('token');
if (is_string($token) && $token !== '') { $credentials = is_string($token) ? $this->magicLinkCredentials($token) : null;
try { if (! $credentials) {
$decrypted = Crypt::decryptString($token); return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
} catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->first();
if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) {
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$invitation->delete();
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
} }
return redirect()->route('login')->with('error', 'Invalid credentials.'); [$user, $invitation] = $credentials;
return view('invitation.accept', [
'invitation' => $invitation,
'team' => $invitation->team,
'alreadyMember' => $user->teams()->where('team_id', $invitation->team_id)->exists(),
'formAction' => route('auth.link.accept'),
'token' => $token,
]);
}
public function acceptLink(Request $request): RedirectResponse
{
$token = $request->input('token');
if (! is_string($token)) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
$acceptedInvitation = DB::transaction(function () use ($token) {
$credentials = $this->magicLinkCredentials($token, lockForUpdate: true);
if (! $credentials) {
return null;
}
[$user, $invitation] = $credentials;
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
$invitation->delete();
return [$user, $team];
});
if (! $acceptedInvitation) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
[$user, $team] = $acceptedInvitation;
Auth::login($user);
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
}
/**
* @return array{0: User, 1: TeamInvitation}|null
*/
private function magicLinkCredentials(string $token, bool $lockForUpdate = false): ?array
{
if ($token === '') {
return null;
}
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return null;
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} elseif (count($payload) === 2) {
[$email, $password] = $payload;
$invitationUuid = null;
} else {
return null;
}
$email = Str::lower($email);
$user = User::query()->where('email', $email)->first();
$invitationQuery = TeamInvitation::query()
->where('email', $email)
->when($lockForUpdate, fn ($query) => $query->lockForUpdate());
$invitation = $invitationUuid
? $invitationQuery->where('uuid', $invitationUuid)->first()
: $invitationQuery->get()->first(
fn (TeamInvitation $invitation) => $this->invitationLinkMatchesToken($invitation, $token)
);
if (! $user || ! $invitation || $invitation->hasExpired() || ! $this->invitationLinkMatchesToken($invitation, $token)) {
return null;
}
return Hash::check($password, $user->password) ? [$user, $invitation] : null;
} }
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
@@ -185,6 +232,7 @@ class Controller extends BaseController
'invitation' => $invitation, 'invitation' => $invitation,
'team' => $invitation->team, 'team' => $invitation->team,
'alreadyMember' => $alreadyMember, 'alreadyMember' => $alreadyMember,
'formAction' => route('team.invitation.accept', $invitation->uuid),
]); ]);
} }
+1
View File
@@ -15,6 +15,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
class Gitlab extends Controller class Gitlab extends Controller
{ {
@@ -41,6 +41,10 @@ class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Si
continue; continue;
} }
if (! $team->members()->whereKey($token->tokenable_id)->exists()) {
continue;
}
$warningSentAt = now(); $warningSentAt = now();
$team->notify(new ApiTokenExpiringNotification($token)); $team->notify(new ApiTokenExpiringNotification($token));
+39 -5
View File
@@ -52,6 +52,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json'; private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const CONTAINER_REMOVE_TIMEOUT_MARKER = '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__';
private const DOCKER_CLIENT_ENV_KEYS = [ private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST', 'BUILDKIT_HOST',
'BUILDX_BUILDER', 'BUILDX_BUILDER',
@@ -612,6 +614,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $this->dockerImagePreviewTag; return $this->dockerImagePreviewTag;
} }
if ($this->rollback && str($this->commit)->isNotEmpty()) {
return $this->commit;
}
if (str($this->application->docker_registry_image_tag)->isNotEmpty()) { if (str($this->application->docker_registry_image_tag)->isNotEmpty()) {
return $this->application->docker_registry_image_tag; return $this->application->docker_registry_image_tag;
} }
@@ -3977,15 +3983,45 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
); );
} else { } else {
$this->execute_remote_command( $this->execute_remote_command(
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true], [dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
); );
$this->removeContainerWithTimeout($containerName);
} }
} catch (Exception $error) { } catch (Exception $error) {
$this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr');
} }
} }
private function removeContainerWithTimeout(string $containerName): void
{
$outputKey = 'container_remove_'.md5($containerName);
$this->execute_remote_command([
dockerRemoveCommandWithTimeout($containerName),
'hidden' => true,
'ignore_errors' => true,
'save' => $outputKey,
'append' => false,
]);
if (! isset($this->saved_outputs)) {
return;
}
$output = (string) $this->saved_outputs->get($outputKey, '');
if (! str_contains($output, self::CONTAINER_REMOVE_TIMEOUT_MARKER)) {
return;
}
$this->application_deployment_queue->addLogEntry(
"Warning: Removing container {$containerName} timed out after 60 seconds. The deployment will continue and cleanup will be retried in 5 minutes.",
'stderr'
);
RemoveContainerJob::dispatch($this->server->id, $containerName)
->delay(now()->addMinutes(5));
}
private function stop_running_container(bool $force = false) private function stop_running_container(bool $force = false)
{ {
try { try {
@@ -5016,9 +5052,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// do not remove already running container for PR deployments // do not remove already running container for PR deployments
} else { } else {
$this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr'); $this->application_deployment_queue->addLogEntry('Deployment failed. Removing the new version of your application.', 'stderr');
$this->execute_remote_command( $this->removeContainerWithTimeout($this->container_name);
["docker rm -f $this->container_name >/dev/null 2>&1", 'hidden' => true, 'ignore_errors' => true]
);
} }
} }
} }
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace App\Jobs;
use App\Actions\Shared\CheckDomainDns;
use App\Models\Application;
use App\Models\Server;
use App\Models\ServiceApplication;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
class CheckDomainDnsJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 30;
public function __construct(
public Application|ServiceApplication $resource,
public string $statusKey,
public string $url,
public ?Server $server,
public ?string $expectedIp,
public string $checkId,
public bool $skipForMultipleServers = false,
) {}
public function handle(): void
{
$this->persistResults(CheckDomainDns::run(
[$this->statusKey => $this->url],
$this->server,
$this->expectedIp,
$this->skipForMultipleServers,
));
}
public function failed(?\Throwable $exception): void
{
$this->persistResults([
$this->statusKey => $this->status('failed', 'Could not validate DNS for this domain.'),
]);
}
/**
* @return array{status: string, message: string, expected_ip: ?string, checked_at: string}
*/
private function status(string $status, string $message): array
{
return [
'status' => $status,
'message' => $message,
'expected_ip' => $this->expectedIp,
'checked_at' => now()->toIso8601String(),
];
}
/**
* @param array<string, array{status: string, message: string, expected_ip: ?string, checked_at: string}> $results
*/
private function persistResults(array $results): void
{
DB::transaction(function () use ($results): void {
$resource = $this->resource::query()->lockForUpdate()->find($this->resource->getKey());
if (! $resource) {
return;
}
$statuses = $resource->domain_dns_statuses ?? [];
foreach ($results as $key => $result) {
if (($statuses[$key]['status'] ?? null) !== 'checking' || ($statuses[$key]['check_id'] ?? null) !== $this->checkId) {
continue;
}
$statuses[$key] = $result;
}
$resource->domain_dns_statuses = $statuses === [] ? null : $statuses;
$resource->save();
});
}
}
+7 -1
View File
@@ -33,10 +33,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
*/ */
public function handle(): void public function handle(): void
{ {
$this->clearOutdatedInfo();
// Detect current version (makes SSH call) // Detect current version (makes SSH call)
$currentVersion = getTraefikVersionFromDockerCompose($this->server); $currentVersion = getTraefikVersionFromDockerCompose($this->server);
// Update detected version in database
$this->server->update(['detected_traefik_version' => $currentVersion]); $this->server->update(['detected_traefik_version' => $currentVersion]);
if (! $currentVersion) { if (! $currentVersion) {
@@ -113,6 +114,11 @@ class CheckTraefikVersionForServerJob implements ShouldBeEncrypted, ShouldQueue
ProxyStatusChangedUI::dispatch($this->server->team_id); ProxyStatusChangedUI::dispatch($this->server->team_id);
} }
private function clearOutdatedInfo(): void
{
$this->server->update(['traefik_outdated_info' => null]);
}
/** /**
* Get information about newer branches if available. * Get information about newer branches if available.
*/ */
+18 -12
View File
@@ -2,8 +2,8 @@
namespace App\Jobs; namespace App\Jobs;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server; use App\Models\Server;
use Carbon\Carbon;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@@ -51,7 +51,9 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
continue; continue;
} }
if ($process['etimes'] >= $minAge && ! file_exists($pathMatch[1])) { if ($process['etimes'] >= $minAge
&& ! file_exists($pathMatch[1])
&& ! SshMultiplexingHelper::isMuxProcessRetiring($process['pid'], $pathMatch[1])) {
$this->reapOrphan('ssh', $process); $this->reapOrphan('ssh', $process);
} }
} }
@@ -169,14 +171,6 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
if ($checkProcess->exitCode() !== 0) { if ($checkProcess->exitCode() !== 0) {
$this->removeMultiplexFile($muxFile, 'connection_check_failed'); $this->removeMultiplexFile($muxFile, 'connection_check_failed');
} else {
$muxContent = Storage::disk('ssh-mux')->get($muxFile);
$establishedAt = Carbon::parse(substr($muxContent, 37));
$expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time'));
if (Carbon::now()->isAfter($expirationTime)) {
$this->removeMultiplexFile($muxFile, 'expired');
}
} }
} }
} }
@@ -216,8 +210,20 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue
} }
$muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}";
$closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; $checkProcess = Process::run("ssh -O check -o ControlPath={$muxSocket} localhost");
Process::run($closeCommand); $pid = preg_match('/pid=(\d+)/', $checkProcess->output().$checkProcess->errorOutput(), $matches)
? $matches[1]
: null;
if ($pid !== null) {
SshMultiplexingHelper::markMuxProcessAsRetiring($pid, $muxSocket);
}
$closeCommand = "ssh -O stop -o ControlPath={$muxSocket} localhost 2>/dev/null";
$stopProcess = Process::run($closeCommand);
if ($pid !== null && ! $stopProcess->successful()) {
SshMultiplexingHelper::unmarkMuxProcessAsRetiring($pid, $muxSocket);
}
Storage::disk('ssh-mux')->delete($muxFile); Storage::disk('ssh-mux')->delete($muxFile);
Log::info('Removed stale mux file', [ Log::info('Removed stale mux file', [
+1 -1
View File
@@ -798,7 +798,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$this->add_to_error_output($e->getMessage()); $this->add_to_error_output($e->getMessage());
throw $e; throw $e;
} finally { } finally {
$command = "docker rm -f backup-of-{$this->backup_log_uuid}"; $command = dockerRemoveCommand("backup-of-{$this->backup_log_uuid}");
instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true); instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
} }
} }
+67 -42
View File
@@ -4,7 +4,6 @@ namespace App\Jobs;
use App\Actions\Application\StopApplication; use App\Actions\Application\StopApplication;
use App\Actions\Database\StopDatabase; use App\Actions\Database\StopDatabase;
use App\Actions\Server\CleanupDocker;
use App\Actions\Service\DeleteService; use App\Actions\Service\DeleteService;
use App\Actions\Service\StopService; use App\Actions\Service\StopService;
use App\Actions\Shared\DeleteScheduledVolumeBackup; use App\Actions\Shared\DeleteScheduledVolumeBackup;
@@ -28,6 +27,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
{ {
@@ -43,20 +44,17 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->onQueue('high'); $this->onQueue('high');
} }
public function handle() public function handle(): void
{ {
if (! $this->resource instanceof ApplicationPreview) { if ($this->resource instanceof ApplicationPreview) {
$this->deleteScheduledVolumeBackups(); DB::transaction(function (): void {
$this->deleteApplicationPreview();
});
return;
} }
try { try {
// Handle ApplicationPreview instances separately
if ($this->resource instanceof ApplicationPreview) {
$this->deleteApplicationPreview();
return;
}
switch ($this->resource->type()) { switch ($this->resource->type()) {
case 'application': case 'application':
StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup); StopApplication::run($this->resource, previewDeployments: true, dockerCleanup: $this->dockerCleanup);
@@ -73,21 +71,71 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
break; break;
case 'service': case 'service':
StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup); StopService::run($this->resource, $this->deleteConnectedNetworks, $this->dockerCleanup);
DeleteService::run($this->resource, $this->deleteVolumes, $this->deleteConnectedNetworks, $this->deleteConfigurations, $this->dockerCleanup); app(DeleteService::class)->cleanupRemote(
$this->resource,
return; $this->deleteVolumes,
$this->deleteConnectedNetworks,
$this->deleteConfigurations,
);
break;
} }
if ($this->deleteConfigurations) { if (! $this->resource instanceof Service) {
$this->resource->deleteConfigurations(); if ($this->deleteConfigurations) {
$this->resource->deleteConfigurations();
}
if ($this->deleteVolumes) {
$this->resource->deleteVolumes();
}
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
$this->resource->deleteConnectedNetworks();
}
} }
} catch (\Throwable $e) {
Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->type(),
'error' => $e->getMessage(),
]);
}
DB::transaction(function (): void {
try {
$this->deleteScheduledVolumeBackups();
} catch (\Throwable $e) {
Log::warning('Remote backup cleanup failed while deleting resource; continuing with local deletion.', [
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->type(),
'error' => $e->getMessage(),
]);
}
if ($this->resource instanceof Service) {
app(DeleteService::class)->deleteLocal($this->resource);
return;
}
if ($this->deleteVolumes) { if ($this->deleteVolumes) {
$this->resource->deleteVolumes();
$this->resource->persistentStorages()->delete(); $this->resource->persistentStorages()->delete();
} }
$this->resource->fileStorages()->delete(); // these are file mounts which should probably have their own flag $this->resource->fileStorages()->delete();
$isDatabase = $this->resource instanceof StandalonePostgresql if ($this->isDatabase()) {
$this->resource->sslCertificates()->delete();
$this->resource->scheduledBackups()->delete();
$this->resource->tags()->detach();
}
$this->resource->environment_variables()->delete();
$this->resource->forceDelete();
});
Artisan::queue('cleanup:stucked-resources');
}
private function isDatabase(): bool
{
return $this->resource instanceof StandalonePostgresql
|| $this->resource instanceof StandaloneRedis || $this->resource instanceof StandaloneRedis
|| $this->resource instanceof StandaloneMongodb || $this->resource instanceof StandaloneMongodb
|| $this->resource instanceof StandaloneMysql || $this->resource instanceof StandaloneMysql
@@ -95,29 +143,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|| $this->resource instanceof StandaloneKeydb || $this->resource instanceof StandaloneKeydb
|| $this->resource instanceof StandaloneDragonfly || $this->resource instanceof StandaloneDragonfly
|| $this->resource instanceof StandaloneClickhouse; || $this->resource instanceof StandaloneClickhouse;
if ($isDatabase) {
$this->resource->sslCertificates()->delete();
$this->resource->scheduledBackups()->delete();
$this->resource->tags()->detach();
}
$this->resource->environment_variables()->delete();
if ($this->deleteConnectedNetworks && $this->resource->type() === 'application') {
$this->resource->deleteConnectedNetworks();
}
} catch (\Throwable $e) {
throw $e;
} finally {
$this->resource->forceDelete();
if ($this->dockerCleanup) {
$server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server');
if ($server) {
CleanupDocker::dispatch($server, false, false);
}
}
Artisan::queue('cleanup:stucked-resources');
}
} }
private function deleteScheduledVolumeBackups(): void private function deleteScheduledVolumeBackups(): void
+34
View File
@@ -155,4 +155,38 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
} }
public function failed(?\Throwable $exception): void
{
$execution = DockerCleanupExecution::query()
->where('server_id', $this->server->id)
->where('status', 'running')
->whereNull('finished_at')
->latest('id')
->first();
if (! $execution) {
return;
}
$message = $exception?->getMessage() ?? 'Docker cleanup job failed without an exception.';
$updated = DockerCleanupExecution::query()
->whereKey($execution->id)
->where('status', 'running')
->whereNull('finished_at')
->update([
'status' => 'failed',
'message' => $message,
'finished_at' => Carbon::now()->toImmutable(),
]);
if ($updated === 0) {
return;
}
$execution->refresh();
event(new DockerCleanupDone($execution));
$this->server->team?->notify(new DockerCleanupFailed($this->server, 'Docker cleanup job failed with the following error: '.$message));
}
} }
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RemoveContainerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 90;
public function __construct(public int $serverId, public string $containerName) {}
public function handle(): void
{
$server = Server::findOrFail($this->serverId);
instant_remote_process(
[dockerRemoveCommandWithTimeout($this->containerName)],
$server,
timeout: 75,
disableMultiplexing: true,
);
}
public function backoff(): array
{
return [300, 900];
}
public function failed(?\Throwable $exception): void
{
Log::warning('Deferred container removal failed', [
'server_id' => $this->serverId,
'container' => $this->containerName,
'error' => $exception?->getMessage(),
]);
}
}
+14 -2
View File
@@ -25,6 +25,8 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
{ {
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const MAX_OUTPUT_SIZE_BYTES = 5 * 1024 * 1024;
/** /**
* The number of times the job may be attempted. * The number of times the job may be attempted.
*/ */
@@ -148,10 +150,12 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
foreach ($this->containers as $containerName) { foreach ($this->containers as $containerName) {
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
$exec = "docker exec {$containerName} {$cmd}"; $dockerCommand = $this->server->isNonRoot() ? 'sudo docker' : 'docker';
$execCommand = "{$dockerCommand} exec {$containerName} {$cmd}";
$exec = $this->boundedTaskCommand($execCommand);
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently // Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
// See: https://github.com/coollabsio/coolify/issues/6736 // See: https://github.com/coollabsio/coolify/issues/6736
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true); $this->task_output = instant_remote_process([$exec], $this->server, throwError: true, no_sudo: true, timeout: $this->timeout, disableMultiplexing: true);
$this->task_log->update([ $this->task_log->update([
'status' => 'success', 'status' => 'success',
'message' => $this->task_output, 'message' => $this->task_output,
@@ -204,6 +208,14 @@ class ScheduledTaskJob implements ShouldBeEncrypted, ShouldQueue
} }
} }
private function boundedTaskCommand(string $command): string
{
$maxOutputBytes = self::MAX_OUTPUT_SIZE_BYTES;
$readLimit = $maxOutputBytes + 1;
return "output_file=\$(mktemp); trap 'rm -f \"\$output_file\"' EXIT; set +e; set -o pipefail; {$command} 2>&1 | { head -c {$readLimit} > \"\$output_file\"; cat > /dev/null; }; exit_code=\${PIPESTATUS[0]}; if [ \"\$(wc -c < \"\$output_file\")\" -gt {$maxOutputBytes} ]; then truncate -s {$maxOutputBytes} \"\$output_file\"; printf '\n\n[... Output truncated at 5MB limit ...]' >> \"\$output_file\"; fi; if [ \"\$exit_code\" -eq 0 ]; then cat \"\$output_file\"; else cat \"\$output_file\" >&2; fi; exit \$exit_code";
}
/** /**
* Calculate the number of seconds to wait before retrying the job. * Calculate the number of seconds to wait before retrying the job.
*/ */
+2 -2
View File
@@ -28,14 +28,14 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
public int $maxExceptions = 1; public int $maxExceptions = 1;
public int $timeout = 3600; public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
private ?ScheduledVolumeBackupExecution $execution = null; private ?ScheduledVolumeBackupExecution $execution = null;
public function __construct(public ScheduledVolumeBackup $backup) public function __construct(public ScheduledVolumeBackup $backup)
{ {
$this->onQueue(crons_queue()); $this->onQueue(crons_queue());
$this->timeout = $backup->timeout ?? 3600; $this->timeout = $backup->timeout ?? ScheduledVolumeBackup::DEFAULT_TIMEOUT;
} }
public function middleware(): array public function middleware(): array
@@ -61,7 +61,7 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
if ($status === 'created') { if ($status === 'created') {
instant_remote_process([ instant_remote_process([
'docker rm -f coolify-proxy', dockerRemoveCommand('coolify-proxy'),
], $server); ], $server);
} }
} }
+14 -1
View File
@@ -29,7 +29,10 @@ class ActivityMonitor extends Component
public static $eventDispatched = false; public static $eventDispatched = false;
protected $listeners = ['activityMonitor' => 'newMonitorActivity']; protected $listeners = [
'activityMonitor' => 'newMonitorActivity',
'processDialogClosed' => 'clearActivity',
];
public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null) public function newMonitorActivity($activityId, $eventToDispatch = 'activityFinished', $eventData = null, $header = null)
{ {
@@ -50,6 +53,16 @@ class ActivityMonitor extends Component
$this->isPollingActive = true; $this->isPollingActive = true;
} }
public function clearActivity(): void
{
$this->activityId = null;
$this->activity = null;
$this->isPollingActive = false;
$this->eventToDispatch = 'activityFinished';
$this->eventData = null;
self::$eventDispatched = false;
}
public function hydrateActivity() public function hydrateActivity()
{ {
if ($this->activityId === null) { if ($this->activityId === null) {
+1 -1
View File
@@ -81,7 +81,7 @@ class Show extends Component
} }
$safeNetwork = escapeshellarg($this->destination->network); $safeNetwork = escapeshellarg($this->destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false); instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $this->destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $this->destination->server); instant_remote_process([dockerNetworkRemoveCommand($this->destination->network)], $this->destination->server);
} }
$this->destination->delete(); $this->destination->delete();
@@ -0,0 +1,31 @@
<?php
namespace App\Livewire\Dev;
use Illuminate\Http\Exceptions\HttpResponseException;
use Livewire\Component;
use Symfony\Component\HttpFoundation\Response;
class LivewireRequestFailurePreview extends Component
{
/**
* @var list<int>
*/
public array $statuses = [502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 527, 530];
public function fail(int $status): never
{
abort_unless(in_array($status, $this->statuses, true), Response::HTTP_NOT_FOUND);
throw new HttpResponseException(response(
'<!doctype html><html><body><h1>Gateway time-out</h1><p>cloudflare proxy error '.$status.'</p></body></html>',
$status,
['Content-Type' => 'text/html']
));
}
public function render(): mixed
{
return view('livewire.dev.livewire-request-failure-preview')->layout('layouts.simple');
}
}
+2 -19
View File
@@ -2,10 +2,8 @@
namespace App\Livewire; namespace App\Livewire;
use App\Actions\Team\DeleteTeam;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class NavbarDeleteTeam extends Component class NavbarDeleteTeam extends Component
@@ -28,22 +26,7 @@ class NavbarDeleteTeam extends Component
$currentTeam = currentTeam(); $currentTeam = currentTeam();
$this->authorize('delete', $currentTeam); $this->authorize('delete', $currentTeam);
$newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
$currentTeam->members->each(function ($user) use ($currentTeam) {
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
refreshSession($newTeam); refreshSession($newTeam);
return redirect()->route('team.index'); return redirect()->route('team.index');
+10
View File
@@ -6,6 +6,7 @@ use App\Livewire\Notifications\Concerns\TogglesNotificationEvents;
use App\Models\EmailNotificationSettings; use App\Models\EmailNotificationSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Test; use App\Notifications\Test;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@@ -57,6 +58,9 @@ class Email extends Component
#[Validate(['nullable', 'numeric'])] #[Validate(['nullable', 'numeric'])]
public ?string $smtpTimeout = null; public ?string $smtpTimeout = null;
#[Validate(['nullable', 'string'])]
public ?string $smtpEhloDomain = null;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $resendEnabled = false; public bool $resendEnabled = false;
@@ -129,6 +133,7 @@ class Email extends Component
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->authorize('update', $this->settings); $this->authorize('update', $this->settings);
$this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
@@ -140,6 +145,7 @@ class Email extends Component
$this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->resend_enabled = $this->resendEnabled; $this->settings->resend_enabled = $this->resendEnabled;
$this->settings->resend_api_key = $this->resendApiKey; $this->settings->resend_api_key = $this->resendApiKey;
@@ -175,6 +181,7 @@ class Email extends Component
? $this->settings->smtp_password ? $this->settings->smtp_password
: null; : null;
$this->smtpTimeout = $this->settings->smtp_timeout; $this->smtpTimeout = $this->settings->smtp_timeout;
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
$this->resendEnabled = $this->settings->resend_enabled; $this->resendEnabled = $this->settings->resend_enabled;
$this->resendApiKey = auth()->user()->can('update', $this->settings) $this->resendApiKey = auth()->user()->can('update', $this->settings)
@@ -261,6 +268,7 @@ class Email extends Component
'smtpUsername' => 'nullable|string', 'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string', 'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric', 'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [ ], [
'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.', 'smtpFromAddress.email' => 'Please enter a valid email address.',
@@ -284,6 +292,7 @@ class Email extends Component
$this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->save(); $this->settings->save();
$this->dispatch('success', 'SMTP settings updated.'); $this->dispatch('success', 'SMTP settings updated.');
@@ -375,6 +384,7 @@ class Email extends Component
$this->smtpUsername = $settings->smtp_username; $this->smtpUsername = $settings->smtp_username;
$this->smtpPassword = $settings->smtp_password; $this->smtpPassword = $settings->smtp_password;
$this->smtpTimeout = $settings->smtp_timeout; $this->smtpTimeout = $settings->smtp_timeout;
$this->smtpEhloDomain = $settings->smtp_ehlo_domain;
if ($settings->resend_enabled) { if ($settings->resend_enabled) {
$this->resendEnabled = true; $this->resendEnabled = true;
+212 -54
View File
@@ -2,6 +2,8 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker; use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Application; use App\Models\Application;
@@ -10,6 +12,7 @@ use App\Support\DomainUrlParts;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class Domains extends Component class Domains extends Component
@@ -141,6 +144,39 @@ class Domains extends Component
$this->loadDomainState(); $this->loadDomainState();
} }
public function pollDnsChecks(): void
{
$this->authorize('view', $this->application);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& ($row['service'] ?? null) === ($checkingRow['service'] ?? null));
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
protected function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
public function toggleNoindexDomain(string $domain, string|bool $indexing): void public function toggleNoindexDomain(string $domain, string|bool $indexing): void
{ {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
@@ -464,6 +500,7 @@ class Domains extends Component
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'), 'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false, 'is_suggested' => false,
'suggested_for' => null, 'suggested_for' => null,
'suggestion_label' => null, 'suggestion_label' => null,
@@ -478,6 +515,7 @@ class Domains extends Component
'dns_message' => 'Not checked yet.', 'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp, 'expected_ip' => $this->serverIp,
'checked_at' => null, 'checked_at' => null,
'check_id' => null,
'is_suggested' => false, 'is_suggested' => false,
'suggested_for' => null, 'suggested_for' => null,
'suggestion_label' => null, 'suggestion_label' => null,
@@ -533,6 +571,8 @@ class Domains extends Component
|| ! $server || ! $server
|| $this->application->additional_servers->count() > 0; || $this->application->additional_servers->count() > 0;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) { foreach ($this->domainRows as $index => $row) {
if ($skipDns) { if ($skipDns) {
$reason = ! $this->dnsValidationEnabled $reason = ! $this->dnsValidationEnabled
@@ -548,7 +588,11 @@ class Domains extends Component
continue; continue;
} }
$this->applyDnsStatus($index, $row['url'], $server); $indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
} }
$this->persistDomainDnsStatuses(); $this->persistDomainDnsStatuses();
@@ -575,45 +619,50 @@ class Domains extends Component
return; return;
} }
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); $this->applyDnsStatus($index, $server);
$this->persistDomainDnsStatuses(); $this->persistDomainDnsStatuses();
} }
protected function applyDnsStatus(int $index, string $url, Server $server): void protected function applyDnsStatus(int $index, Server $server): void
{ {
$target = $this->dnsTargetLabel(); $this->applyDnsStatuses([$index], $server);
}
try { /**
$isValid = validateDNSEntry($url, $server); * @param array<int, int> $indexes
if ($isValid) { */
$this->domainRows[$index]['dns_status'] = 'ok'; protected function applyDnsStatuses(array $indexes, Server $server): void
$this->domainRows[$index]['dns_message'] = $target {
? "DNS points to {$target} (or Cloudflare)." $entries = [];
: 'DNS looks correct.';
} else { foreach ($indexes as $index) {
$this->domainRows[$index]['dns_status'] = 'failed'; $entries[(string) $index] = $this->domainRows[$index]['url'];
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp); }
$results = CheckDomainDns::run($entries, $server, $this->serverIp);
foreach ($results as $index => $result) {
$index = (int) $index;
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
// Keep suggested-row copy short after DNS checks (no role badge).
if ($this->domainRows[$index]['is_suggested'] ?? false) {
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.');
$serviceName = $this->domainRows[$index]['service'] ?? null;
$meta = $this->suggestedDomainMeta(
$isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
);
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
} }
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
}
// Keep suggested-row copy short after DNS checks (no role badge). $this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
if ($this->domainRows[$index]['is_suggested'] ?? false) { $this->domainRows[$index]['checked_at'] = $result['checked_at'];
$isWww = str_starts_with(strtolower((string) $this->domainHost((string) $this->domainRows[$index]['url'])), 'www.'); $this->domainRows[$index]['check_id'] = null;
$serviceName = $this->domainRows[$index]['service'] ?? null;
$meta = $this->suggestedDomainMeta(
$isWww,
$this->serviceRedirectFor(is_string($serviceName) ? $serviceName : null)
);
$this->domainRows[$index]['dns_message'] = $meta['pending_message'];
$this->domainRows[$index]['suggestion_label'] = null;
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
} }
$this->domainRows[$index]['expected_ip'] = $this->serverIp;
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
} }
/** /**
@@ -647,11 +696,34 @@ class Domains extends Component
'message' => (string) ($row['dns_message'] ?? ''), 'message' => (string) ($row['dns_message'] ?? ''),
'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
'check_id' => $row['check_id'] ?? null,
]; ];
} }
DB::transaction(function () use (&$statuses): void {
$application = Application::query()->lockForUpdate()->findOrFail($this->application->id);
$storedStatuses = $application->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
$statuses[$key] = $storedStatuses[$key];
continue;
}
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
$statuses[$key] = $storedStatuses[$key];
}
}
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$application->save();
});
$this->application->domain_dns_statuses = $statuses === [] ? null : $statuses; $this->application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$this->application->save();
} }
protected function pruneDomainDnsStatusesToCurrentDomains(): void protected function pruneDomainDnsStatusesToCurrentDomains(): void
@@ -804,16 +876,6 @@ class Domains extends Component
} }
} }
if (! $this->forceSaveDns && $this->shouldValidateDnsForAdd()) {
$dnsFailure = $this->findDnsFailureMessage($newUrls);
if ($dnsFailure !== null) {
$this->addDomainDnsFailed = true;
$this->addDomainDnsMessage = $dnsFailure;
return;
}
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add'; $this->pendingAction = 'add';
if (! $this->saveDomainList($merged, $this->newDomainService)) { if (! $this->saveDomainList($merged, $this->newDomainService)) {
@@ -825,14 +887,110 @@ class Domains extends Component
$serviceForCheck = $this->newDomainService; $serviceForCheck = $this->newDomainService;
$this->resetAddDomainForm(); $this->resetAddDomainForm();
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), $serviceForCheck); $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
->map(fn (string $url, string $statusKey) => [
'status_key' => $statusKey,
'url' => $url,
'check_id' => new_public_id(),
]);
foreach ($dnsChecks as $dnsCheck) {
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
}
$this->persistDomainDnsStatuses();
$failedDnsChecks = 0;
foreach ($dnsChecks as $dnsCheck) {
try {
CheckDomainDnsJob::dispatch(
$this->application,
$dnsCheck['status_key'],
$dnsCheck['url'],
$this->application->destination?->server,
$this->serverIp,
$dnsCheck['check_id'],
$this->application->additional_servers->count() > 0,
);
} catch (\Throwable) {
$failedDnsChecks++;
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceForCheck, $dnsCheck['check_id']);
}
}
if ($failedDnsChecks > 0) {
$this->persistDomainDnsStatuses();
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
}
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
? 'Domain added.'
: 'Domain added. DNS check started.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
} }
/**
* @param array<int, string> $urls
*/
protected function markUrlsAsChecking(array $urls, ?string $service = null, ?string $checkId = null): void
{
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ($service !== null && ($row['service'] ?? null) !== $service) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsDnsCheckUnavailable(array $urls, ?string $service = null, ?string $checkId = null): void
{
$this->markUrlsAsChecking($urls, $service, $checkId);
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ($service !== null && ($row['service'] ?? null) !== $service) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
/**
* @param array<int, string> $urls
* @return array<string, string>
*/
protected function dnsEntriesForUrls(array $urls, ?string $service = null): array
{
$entries = [];
foreach ($urls as $url) {
$entries[$this->domainDnsStatusKey($url, $service)] = $url;
}
return $entries;
}
/** /**
* Run a first-time DNS check for newly added/updated domain URLs and persist results. * Run a first-time DNS check for newly added/updated domain URLs and persist results.
* *
@@ -875,7 +1033,11 @@ class Domains extends Component
continue; continue;
} }
$this->applyDnsStatus($index, $url, $server); $indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
} }
$this->persistDomainDnsStatuses(); $this->persistDomainDnsStatuses();
@@ -909,15 +1071,11 @@ class Domains extends Component
return null; return null;
} }
$target = $this->dnsTargetLabel() ?? $server->ip; $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
foreach ($urls as $url) { foreach ($results as $result) {
try { if ($result['status'] === 'failed') {
if (! validateDNSEntry($url, $server)) { return $result['message'];
return dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
return 'Could not validate DNS for this domain.';
} }
} }
+26 -2
View File
@@ -796,6 +796,8 @@ EOD;
* *
* Hardened against bypasses: * Hardened against bypasses:
* - decompresses gzip backups before scanning, * - decompresses gzip backups before scanning,
* - converts custom-format (PGDMP) archives to SQL with pg_restore
* before scanning, and rejects archives that cannot be inspected,
* - strips `--` line comments and flattens newlines so multi-line and * - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught, * comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects. * - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
@@ -817,8 +819,30 @@ EOD;
$escapedSqlPattern = escapeshellarg($sqlPattern); $escapedSqlPattern = escapeshellarg($sqlPattern);
$escapedPsqlPattern = escapeshellarg($psqlPattern); $escapedPsqlPattern = escapeshellarg($psqlPattern);
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }"; $contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
$scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
$customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
$sqlScan = $scan($contents);
$blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
$blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
return "header=\$({$contents} | head -c 5); if [ \"\$header\" = 'PGDMP' ]; then exit 0; fi; if {$contents} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$contents} | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedSqlPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi"; return <<<SH
header=\$({$contents} | head -c 5)
if [ "\$header" = 'PGDMP' ]; then
inspect=\$(mktemp)
trap 'rm -f "\$inspect"' EXIT
if ! {$contents} > "\$inspect"; then
{$blockedInspect}
fi
if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
{$blockedInspect}
fi
if {$customScan}; then
{$blockedProgram}
fi
elif {$sqlScan}; then
{$blockedProgram}
fi
SH;
} }
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
@@ -883,7 +907,7 @@ EOD;
case 'postgresql': case 'postgresql':
$restoreCommand = $this->postgresqlRestoreCommand; $restoreCommand = $this->postgresqlRestoreCommand;
if ($this->dumpAll) { if ($this->dumpAll) {
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}"; $restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
} else { } else {
$restoreCommand .= " {$escapedTmpPath}"; $restoreCommand .= " {$escapedTmpPath}";
} }
+11 -4
View File
@@ -26,10 +26,17 @@ class Configuration extends Component
public array $parameters; public array $parameters;
protected $listeners = [ public function getListeners(): array
'refreshServices' => 'refreshServices', {
'refresh' => 'refreshServices', $teamId = auth()->user()->currentTeam()->id;
];
return [
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
'configurationChanged' => 'refreshServices',
"echo-private:team.{$teamId},ApplicationConfigurationChanged" => 'refreshServices',
];
}
public function render() public function render()
{ {
+182 -43
View File
@@ -2,6 +2,8 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Actions\Shared\CheckDomainDns;
use App\Jobs\CheckDomainDnsJob;
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect; use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
use App\Livewire\Project\Shared\ConfigurationChecker; use App\Livewire\Project\Shared\ConfigurationChecker;
use App\Models\Server; use App\Models\Server;
@@ -131,6 +133,39 @@ class Domains extends Component
$this->loadDomainState(); $this->loadDomainState();
} }
public function pollDnsChecks(): void
{
$this->authorize('view', $this->service);
$checkingRows = collect($this->domainRows)
->where('dns_status', 'checking')
->values();
$this->refreshDomains();
foreach ($checkingRows as $checkingRow) {
$row = collect($this->domainRows)->first(fn (array $row): bool => $row['url'] === $checkingRow['url']
&& (int) $row['service_application_id'] === (int) $checkingRow['service_application_id']);
if (! is_array($row) || $row['dns_status'] === 'checking') {
continue;
}
$this->dispatchDnsCheckNotification($row['url'], $row['dns_status']);
}
}
protected function dispatchDnsCheckNotification(string $url, string $status): void
{
$host = parse_url($url, PHP_URL_HOST) ?: $url;
match ($status) {
'ok' => $this->dispatch('success', "DNS is configured correctly for {$host}."),
'failed' => $this->dispatch('error', "DNS is not configured for {$host}. Review the required DNS record."),
default => $this->dispatch('info', "DNS check skipped for {$host}."),
};
}
public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void public function toggleNoindexDomain(int $serviceApplicationId, string $domain, string|bool $indexing): void
{ {
$application = $this->service->applications()->findOrFail($serviceApplicationId); $application = $this->service->applications()->findOrFail($serviceApplicationId);
@@ -282,6 +317,7 @@ class Domains extends Component
'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'), 'dns_message' => (string) data_get($entry, 'message', 'Not checked yet.'),
'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp, 'expected_ip' => data_get($entry, 'expected_ip') ?: $this->serverIp,
'checked_at' => data_get($entry, 'checked_at'), 'checked_at' => data_get($entry, 'checked_at'),
'check_id' => data_get($entry, 'check_id'),
'is_suggested' => false, 'is_suggested' => false,
'suggested_for' => null, 'suggested_for' => null,
'suggestion_label' => null, 'suggestion_label' => null,
@@ -298,6 +334,7 @@ class Domains extends Component
'dns_message' => 'Not checked yet.', 'dns_message' => 'Not checked yet.',
'expected_ip' => $this->serverIp, 'expected_ip' => $this->serverIp,
'checked_at' => null, 'checked_at' => null,
'check_id' => null,
'is_suggested' => false, 'is_suggested' => false,
'suggested_for' => null, 'suggested_for' => null,
'suggestion_label' => null, 'suggestion_label' => null,
@@ -404,6 +441,8 @@ class Domains extends Component
$server = $this->service->server; $server = $this->service->server;
$skipDns = ! $this->dnsValidationEnabled || ! $server; $skipDns = ! $this->dnsValidationEnabled || ! $server;
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) { foreach ($this->domainRows as $index => $row) {
if ($skipDns) { if ($skipDns) {
$this->domainRows[$index]['dns_status'] = 'skipped'; $this->domainRows[$index]['dns_status'] = 'skipped';
@@ -415,7 +454,11 @@ class Domains extends Component
continue; continue;
} }
$this->applyDnsStatus($index, $row['url'], $server); $indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
} }
$this->persistAllDomainDnsStatuses(); $this->persistAllDomainDnsStatuses();
@@ -443,33 +486,37 @@ class Domains extends Component
return; return;
} }
$this->applyDnsStatus($index, $this->domainRows[$index]['url'], $server); $this->applyDnsStatus($index, $server);
$this->persistAllDomainDnsStatuses(); $this->persistAllDomainDnsStatuses();
} }
protected function applyDnsStatus(int $index, string $url, Server $server): void protected function applyDnsStatus(int $index, Server $server): void
{ {
$target = $this->dnsTargetLabel(); $this->applyDnsStatuses([$index], $server);
}
try { /**
$isValid = validateDNSEntry($url, $server); * @param array<int, int> $indexes
if ($isValid) { */
$this->domainRows[$index]['dns_status'] = 'ok'; protected function applyDnsStatuses(array $indexes, Server $server): void
$this->domainRows[$index]['dns_message'] = $target {
? "DNS points to {$target} (or Cloudflare)." $entries = [];
: 'DNS looks correct.';
} else { foreach ($indexes as $index) {
$this->domainRows[$index]['dns_status'] = 'failed'; $entries[(string) $index] = $this->domainRows[$index]['url'];
$this->domainRows[$index]['dns_message'] = dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
$this->domainRows[$index]['dns_status'] = 'failed';
$this->domainRows[$index]['dns_message'] = 'Could not validate DNS for this domain.';
} }
$this->domainRows[$index]['expected_ip'] = $this->serverIp; $results = CheckDomainDns::run($entries, $server, $this->serverIp);
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
$this->decorateSuggestedDomainAfterDnsCheck($index); foreach ($results as $index => $result) {
$index = (int) $index;
$this->domainRows[$index]['dns_status'] = $result['status'];
$this->domainRows[$index]['dns_message'] = $result['message'];
$this->domainRows[$index]['expected_ip'] = $result['expected_ip'];
$this->domainRows[$index]['checked_at'] = $result['checked_at'];
$this->domainRows[$index]['check_id'] = null;
$this->decorateSuggestedDomainAfterDnsCheck($index);
}
} }
/** /**
@@ -516,6 +563,7 @@ class Domains extends Component
'message' => (string) ($row['dns_message'] ?? ''), 'message' => (string) ($row['dns_message'] ?? ''),
'expected_ip' => $row['expected_ip'] ?? $this->serverIp, 'expected_ip' => $row['expected_ip'] ?? $this->serverIp,
'checked_at' => $row['checked_at'] ?? now()->toIso8601String(), 'checked_at' => $row['checked_at'] ?? now()->toIso8601String(),
'check_id' => $row['check_id'] ?? null,
]; ];
} }
@@ -528,8 +576,30 @@ class Domains extends Component
->all(); ->all();
$statuses = array_intersect_key($statuses, array_flip($currentUrls)); $statuses = array_intersect_key($statuses, array_flip($currentUrls));
DB::transaction(function () use ($app, &$statuses): void {
$application = ServiceApplication::query()->lockForUpdate()->findOrFail($app->id);
$storedStatuses = $application->domain_dns_statuses ?? [];
foreach ($statuses as $key => $status) {
$localCheckId = $status['check_id'] ?? null;
$storedCheckId = $storedStatuses[$key]['check_id'] ?? null;
if ($storedCheckId !== null && $localCheckId !== $storedCheckId) {
$statuses[$key] = $storedStatuses[$key];
continue;
}
if ($status['status'] === 'checking' && isset($storedStatuses[$key]) && $storedStatuses[$key]['status'] !== 'checking') {
$statuses[$key] = $storedStatuses[$key];
}
}
$application->domain_dns_statuses = $statuses === [] ? null : $statuses;
$application->save();
});
$app->domain_dns_statuses = $statuses === [] ? null : $statuses; $app->domain_dns_statuses = $statuses === [] ? null : $statuses;
$app->save();
} }
$this->service->load('applications'); $this->service->load('applications');
@@ -928,16 +998,6 @@ class Domains extends Component
} }
} }
if (! $this->forceSaveDns && $this->shouldValidateDns()) {
$dnsFailure = $this->findDnsFailureMessage($newUrls);
if ($dnsFailure !== null) {
$this->addDomainDnsFailed = true;
$this->addDomainDnsMessage = $dnsFailure;
return;
}
}
$merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values(); $merged = $current->merge($newUrls)->merge($pairedUrls)->unique()->values();
$this->pendingAction = 'add'; $this->pendingAction = 'add';
@@ -955,14 +1015,93 @@ class Domains extends Component
$this->forceRemovePort = false; $this->forceRemovePort = false;
$this->pendingAction = null; $this->pendingAction = null;
$this->dispatch('close-modal'); $this->dispatch('close-modal');
$this->dispatch('success', 'Domain added.');
$this->refreshDomains(); $this->refreshDomains();
$this->checkUrlsDns(array_values(array_unique(array_merge($newUrls, $pairedUrls))), (int) $app->id); $urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
$serviceApplicationId = (int) $app->id;
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
'url' => $url,
'check_id' => new_public_id(),
]);
foreach ($dnsChecks as $dnsCheck) {
$this->markUrlsAsChecking([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
}
$this->persistAllDomainDnsStatuses();
$failedDnsChecks = 0;
foreach ($dnsChecks as $dnsCheck) {
try {
CheckDomainDnsJob::dispatch(
$app,
$dnsCheck['url'],
$dnsCheck['url'],
$this->service->server,
$this->serverIp,
$dnsCheck['check_id'],
);
} catch (\Throwable) {
$failedDnsChecks++;
$this->markUrlsDnsCheckUnavailable([$dnsCheck['url']], $serviceApplicationId, $dnsCheck['check_id']);
}
}
if ($failedDnsChecks > 0) {
$this->persistAllDomainDnsStatuses();
$this->dispatch('error', 'Some DNS checks could not be started. Try again from the Domains page.');
}
$this->dispatch('success', $failedDnsChecks === $dnsChecks->count()
? 'Domain added.'
: 'Domain added. DNS check started.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
handleError($e, $this); handleError($e, $this);
} }
} }
/**
* @param array<int, string> $urls
*/
protected function markUrlsAsChecking(array $urls, int $serviceApplicationId, ?string $checkId = null): void
{
$indexesToCheck = [];
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'checking';
$this->domainRows[$index]['dns_message'] = 'Checking DNS...';
$this->domainRows[$index]['check_id'] = $checkId;
}
}
/**
* @param array<int, string> $urls
*/
protected function markUrlsDnsCheckUnavailable(array $urls, int $serviceApplicationId, ?string $checkId = null): void
{
$this->markUrlsAsChecking($urls, $serviceApplicationId, $checkId);
foreach ($this->domainRows as $index => $row) {
if (! in_array($row['url'], $urls, true)) {
continue;
}
if ((int) ($row['service_application_id'] ?? 0) !== $serviceApplicationId) {
continue;
}
$this->domainRows[$index]['dns_status'] = 'skipped';
$this->domainRows[$index]['dns_message'] = 'DNS check could not be started.';
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
}
}
public function startEdit(int $index): void public function startEdit(int $index): void
{ {
if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) { if (! isset($this->domainRows[$index]) || ($this->domainRows[$index]['is_suggested'] ?? false)) {
@@ -1309,7 +1448,11 @@ class Domains extends Component
continue; continue;
} }
$this->applyDnsStatus($index, $url, $server); $indexesToCheck[] = $index;
}
if ($server && $indexesToCheck !== []) {
$this->applyDnsStatuses($indexesToCheck, $server);
} }
$this->persistAllDomainDnsStatuses(); $this->persistAllDomainDnsStatuses();
@@ -1330,15 +1473,11 @@ class Domains extends Component
return null; return null;
} }
$target = $this->dnsTargetLabel() ?? $server->ip; $results = CheckDomainDns::run(array_combine($urls, $urls), $server, $this->serverIp);
foreach ($urls as $url) { foreach ($results as $result) {
try { if ($result['status'] === 'failed') {
if (! validateDNSEntry($url, $server)) { return $result['message'];
return dnsMismatchGuidanceMessage($target, $this->serverIp);
}
} catch (\Throwable) {
return 'Could not validate DNS for this domain.';
} }
} }
@@ -99,8 +99,8 @@ class Create extends Component
$label = str($resource->name)->headline(); $label = str($resource->name)->headline();
$targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [ $targets->push(...$resource->persistentStorages()->orderBy('name')->get()->map(fn (LocalPersistentVolume $volume): array => [
'key' => 'volume:'.$volume->id, 'key' => 'volume:'.$volume->id,
'type' => 'Volume · '.$label, 'type' => $label,
'name' => $volume->name, 'name' => str($volume->name)->after($this->service->uuid.'_')->value(),
])); ]));
$targets->push(...$resource->fileStorages() $targets->push(...$resource->fileStorages()
->where('is_directory', true) ->where('is_directory', true)
@@ -109,8 +109,8 @@ class Create extends Component
->get() ->get()
->map(fn (LocalFileVolume $directory): array => [ ->map(fn (LocalFileVolume $directory): array => [
'key' => 'directory:'.$directory->id, 'key' => 'directory:'.$directory->id,
'type' => 'Directory · '.$label, 'type' => $label,
'name' => $directory->fs_path, 'name' => $directory->fs_path.' (directory)',
])); ]));
} }
@@ -2,6 +2,7 @@
namespace App\Livewire\Project\Shared\EnvironmentVariable; namespace App\Livewire\Project\Shared\EnvironmentVariable;
use App\Events\ApplicationConfigurationChanged;
use App\Models\Application; use App\Models\Application;
use App\Models\Environment; use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable; use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
@@ -298,6 +299,10 @@ class Show extends Component
$this->dispatch('success', 'Environment variable updated.'); $this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated'); $this->dispatch('envsUpdated');
$this->dispatch('configurationChanged'); $this->dispatch('configurationChanged');
if ($this->is_required && $this->resource instanceof Service) {
event(new ApplicationConfigurationChanged($this->resource->team()->id));
}
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e); return handleError($e);
} }
+34 -8
View File
@@ -25,6 +25,8 @@ class GetLogs extends Component
{ {
public const MAX_LOG_LINES = 50000; public const MAX_LOG_LINES = 50000;
public const MAX_DISPLAY_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB public const MAX_DOWNLOAD_SIZE_BYTES = 50 * 1024 * 1024; // 50MB
public string $outputs = ''; public string $outputs = '';
@@ -154,14 +156,12 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; $command = "docker logs -n {$this->numberOfLines} -t {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} else { } else {
if ($this->server->isSwarm()) { if ($this->server->isSwarm()) {
@@ -170,22 +170,39 @@ class GetLogs extends Component
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} else { } else {
$command = "docker logs -n {$this->numberOfLines} {$this->container}"; $command = "docker logs -n {$this->numberOfLines} {$this->container}";
if ($this->server->isNonRoot()) { if ($this->server->isNonRoot()) {
$command = parseCommandsByLineForSudo(collect($command), $this->server); $command = parseCommandsByLineForSudo(collect($command), $this->server);
$command = $command[0]; $command = $command[0];
} }
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
} }
} }
$command = $this->boundedLogCommand($command, self::MAX_DISPLAY_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Collect new logs into temporary variable first to prevent flickering // Collect new logs into temporary variable first to prevent flickering
// (avoids clearing output before new data is ready) // (avoids clearing output before new data is ready)
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation // Use array accumulation + implode for O(n) instead of O(n²) string concatenation
$logChunks = []; $logChunks = [];
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks) { $accumulatedBytes = 0;
$truncated = false;
Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand, function (string $type, string $output) use (&$logChunks, &$accumulatedBytes, &$truncated) {
if ($truncated) {
return;
}
$remainingBytes = self::MAX_DISPLAY_SIZE_BYTES - $accumulatedBytes;
$outputBytes = strlen($output);
if ($outputBytes > $remainingBytes) {
$logChunks[] = removeAnsiColors(substr($output, 0, max(0, $remainingBytes)));
$truncated = true;
return;
}
$logChunks[] = removeAnsiColors($output); $logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes;
}); });
$newOutputs = implode('', $logChunks); $newOutputs = implode('', $logChunks);
@@ -198,6 +215,10 @@ class GetLogs extends Component
})->join("\n"); })->join("\n");
} }
if ($truncated) {
$newOutputs .= "\n\n[... Output truncated at 5MB limit ...]";
}
// Only update outputs after new data is ready (atomic update prevents flicker) // Only update outputs after new data is ready (atomic update prevents flicker)
$this->outputs = $newOutputs; $this->outputs = $newOutputs;
} }
@@ -239,6 +260,7 @@ class GetLogs extends Component
$command = $command[0]; $command = $command[0];
} }
$command = $this->boundedLogCommand($command, self::MAX_DOWNLOAD_SIZE_BYTES);
$sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command);
// Use array accumulation + implode for O(n) instead of O(n²) string concatenation // Use array accumulation + implode for O(n) instead of O(n²) string concatenation
@@ -252,20 +274,19 @@ class GetLogs extends Component
return; return;
} }
$output = removeAnsiColors($output);
$outputBytes = strlen($output); $outputBytes = strlen($output);
if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) { if ($accumulatedBytes + $outputBytes > self::MAX_DOWNLOAD_SIZE_BYTES) {
$remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes; $remaining = self::MAX_DOWNLOAD_SIZE_BYTES - $accumulatedBytes;
if ($remaining > 0) { if ($remaining > 0) {
$logChunks[] = substr($output, 0, $remaining); $logChunks[] = removeAnsiColors(substr($output, 0, $remaining));
} }
$truncated = true; $truncated = true;
return; return;
} }
$logChunks[] = $output; $logChunks[] = removeAnsiColors($output);
$accumulatedBytes += $outputBytes; $accumulatedBytes += $outputBytes;
}); });
@@ -287,6 +308,11 @@ class GetLogs extends Component
return sanitizeLogsForExport($allLogs); return sanitizeLogsForExport($allLogs);
} }
private function boundedLogCommand(string $command, int $maxBytes): string
{
return "({$command}) 2>&1 | head -c ".($maxBytes + 1);
}
public function render() public function render()
{ {
return view('livewire.project.shared.get-logs'); return view('livewire.project.shared.get-logs');
@@ -2,7 +2,10 @@
namespace App\Livewire\Project\Shared\ScheduledTask; namespace App\Livewire\Project\Shared\ScheduledTask;
use App\Models\Application;
use App\Models\ScheduledTask; use App\Models\ScheduledTask;
use App\Models\Service;
use App\Models\StandalonePostgresql;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@@ -59,13 +62,13 @@ class Add extends Component
// Get the resource based on type and id // Get the resource based on type and id
switch ($this->type) { switch ($this->type) {
case 'application': case 'application':
$this->resource = \App\Models\Application::findOrFail($this->id); $this->resource = Application::ownedByCurrentTeam()->findOrFail($this->id);
break; break;
case 'service': case 'service':
$this->resource = \App\Models\Service::findOrFail($this->id); $this->resource = Service::ownedByCurrentTeam()->findOrFail($this->id);
break; break;
case 'standalone-postgresql': case 'standalone-postgresql':
$this->resource = \App\Models\StandalonePostgresql::findOrFail($this->id); $this->resource = StandalonePostgresql::ownedByCurrentTeam()->findOrFail($this->id);
break; break;
default: default:
throw new \Exception('Invalid resource type'); throw new \Exception('Invalid resource type');
@@ -10,6 +10,7 @@ use Livewire\Component;
class Executions extends Component class Executions extends Component
{ {
#[Locked]
public ScheduledTask $task; public ScheduledTask $task;
#[Locked] #[Locked]
@@ -28,6 +29,7 @@ class Executions extends Component
public $logsPerPage = 100; public $logsPerPage = 100;
#[Locked]
public $selectedExecution = null; public $selectedExecution = null;
public $isPollingActive = false; public $isPollingActive = false;
@@ -45,7 +47,7 @@ class Executions extends Component
{ {
try { try {
$this->taskId = $taskId; $this->taskId = $taskId;
$this->task = ScheduledTask::findOrFail($taskId); $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
$this->executions = $this->task->executions()->take(20)->get(); $this->executions = $this->task->executions()->take(20)->get();
$this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone'); $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
if (! $this->serverTimezone) { if (! $this->serverTimezone) {
@@ -15,8 +15,10 @@ class Show extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
#[Locked]
public Application|Service $resource; public Application|Service $resource;
#[Locked]
public ScheduledTask $task; public ScheduledTask $task;
#[Locked] #[Locked]
@@ -115,6 +117,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->isEnabled = ! $this->isEnabled; $this->isEnabled = ! $this->isEnabled;
$this->task->enabled = $this->isEnabled; $this->task->enabled = $this->isEnabled;
$this->task->save(); $this->task->save();
@@ -128,6 +131,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.'); $this->dispatch('success', 'Scheduled task updated.');
$this->refreshTasks(); $this->refreshTasks();
@@ -140,6 +144,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Scheduled task updated.'); $this->dispatch('success', 'Scheduled task updated.');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -160,6 +165,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('delete', $this->task);
$this->task->delete(); $this->task->delete();
if ($this->type === 'application') { if ($this->type === 'application') {
@@ -176,6 +182,7 @@ class Show extends Component
{ {
try { try {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->authorize('update', $this->task);
ScheduledTaskJob::dispatch($this->task); ScheduledTaskJob::dispatch($this->task);
$this->dispatch('success', 'Scheduled task executed.'); $this->dispatch('success', 'Scheduled task executed.');
} catch (\Exception $e) { } catch (\Exception $e) {
@@ -56,7 +56,7 @@ class VolumeBackups extends Component
public string $timezone = ''; public string $timezone = '';
public int $timeout = 3600; public int $timeout = ScheduledVolumeBackup::DEFAULT_TIMEOUT;
public int $perPage = 10; public int $perPage = 10;
+8 -2
View File
@@ -59,7 +59,10 @@ class ApiTokens extends Component
private function getTokens() private function getTokens()
{ {
$this->tokens = auth()->user()->tokens->sortByDesc('created_at'); $this->tokens = auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->latest()
->get();
} }
public function updatedPermissions($permissionToUpdate) public function updatedPermissions($permissionToUpdate)
@@ -148,7 +151,10 @@ class ApiTokens extends Component
public function revoke(int $id) public function revoke(int $id)
{ {
try { try {
$token = auth()->user()->tokens()->where('id', $id)->firstOrFail(); $token = auth()->user()->tokens()
->where('team_id', currentTeam()->id)
->where('id', $id)
->firstOrFail();
$this->authorize('delete', $token); $this->authorize('delete', $token);
$token->delete(); $token->delete();
$this->getTokens(); $this->getTokens();
@@ -2,7 +2,6 @@
namespace App\Livewire\Server; namespace App\Livewire\Server;
use App\Models\DockerCleanupExecution;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
@@ -46,7 +45,7 @@ class DockerCleanupExecutions extends Component
->get(); ->get();
if ($this->selectedKey) { if ($this->selectedKey) {
$this->selectedExecution = DockerCleanupExecution::find($this->selectedKey); $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($this->selectedKey);
if ($this->selectedExecution && $this->selectedExecution->status !== 'running') { if ($this->selectedExecution && $this->selectedExecution->status !== 'running') {
$this->isPollingActive = false; $this->isPollingActive = false;
} }
@@ -64,7 +63,7 @@ class DockerCleanupExecutions extends Component
return; return;
} }
$this->selectedKey = $key; $this->selectedKey = $key;
$this->selectedExecution = DockerCleanupExecution::find($key); $this->selectedExecution = $this->server->dockerCleanupExecutions()->find($key);
$this->currentPage = 1; $this->currentPage = 1;
if ($this->selectedExecution && $this->selectedExecution->status === 'running') { if ($this->selectedExecution && $this->selectedExecution->status === 'running') {
+16 -3
View File
@@ -163,6 +163,7 @@ class Navbar extends Component
$previousStatus = $this->proxyStatus; $previousStatus = $this->proxyStatus;
$this->server->refresh(); $this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown'; $this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$this->dispatchProxyConfigurationState();
// If event contains activityId, open activity monitor // If event contains activityId, open activity monitor
if ($event && isset($event['activityId'])) { if ($event && isset($event['activityId'])) {
@@ -227,6 +228,16 @@ class Navbar extends Component
{ {
$this->server->refresh(); $this->server->refresh();
$this->server->load('settings'); $this->server->load('settings');
$this->dispatchProxyConfigurationState();
}
private function dispatchProxyConfigurationState(): void
{
$this->dispatch(
'proxy-configuration-state-changed',
pending: $this->server->hasPendingProxyConfiguration(),
traefikOutdated: $this->server->hasCurrentTraefikOutdatedInfo(),
);
} }
public function refreshSentinelStatus($event = null): void public function refreshSentinelStatus($event = null): void
@@ -248,10 +259,12 @@ class Navbar extends Component
return false; return false;
} }
// Check if server has outdated info stored return $this->server->hasCurrentTraefikOutdatedInfo();
$outdatedInfo = $this->server->traefik_outdated_info; }
return ! empty($outdatedInfo) && isset($outdatedInfo['type']); public function getHasPendingProxyConfigurationProperty(): bool
{
return $this->server->hasPendingProxyConfiguration();
} }
public function render() public function render()
+5 -1
View File
@@ -161,6 +161,7 @@ class Proxy extends Component
$this->server->proxy->redirect_url = $this->redirectUrl; $this->server->proxy->redirect_url = $this->redirectUrl;
$this->server->save(); $this->server->save();
$this->server->setupDefaultRedirect(); $this->server->setupDefaultRedirect();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration saved.'); $this->dispatch('success', 'Proxy configuration saved.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -175,6 +176,7 @@ class Proxy extends Component
$this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true); $this->proxySettings = GetProxyConfiguration::run($this->server, forceRegenerate: true);
SaveProxyConfiguration::run($this->server, $this->proxySettings); SaveProxyConfiguration::run($this->server, $this->proxySettings);
$this->server->save(); $this->server->save();
$this->dispatch('refreshServerShow');
$this->dispatch('success', 'Proxy configuration reset to default.'); $this->dispatch('success', 'Proxy configuration reset to default.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@@ -276,7 +278,9 @@ class Proxy extends Component
// Check if we have outdated info stored for this server (faster than computing) // Check if we have outdated info stored for this server (faster than computing)
$outdatedInfo = $this->server->traefik_outdated_info; $outdatedInfo = $this->server->traefik_outdated_info;
if ($outdatedInfo && isset($outdatedInfo['type']) && $outdatedInfo['type'] === 'minor_upgrade') { $storedCurrentVersion = ltrim((string) data_get($outdatedInfo, 'current'), 'v');
$detectedCurrentVersion = ltrim($currentVersion, 'v');
if ($storedCurrentVersion === $detectedCurrentVersion && data_get($outdatedInfo, 'type') === 'minor_upgrade') {
// Use the upgrade_target field if available (e.g., "v3.6") // Use the upgrade_target field if available (e.g., "v3.6")
if (isset($outdatedInfo['upgrade_target'])) { if (isset($outdatedInfo['upgrade_target'])) {
return str_starts_with($outdatedInfo['upgrade_target'], 'v') return str_starts_with($outdatedInfo['upgrade_target'], 'v')
@@ -11,6 +11,12 @@ class DynamicConfigurations extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public const MAX_CONFIGURATION_FILE_SIZE_BYTES = 1024 * 1024;
public const MAX_TOTAL_CONFIGURATION_SIZE_BYTES = 5 * 1024 * 1024;
public const MAX_CONFIGURATION_FILES = 100;
public ?Server $server = null; public ?Server $server = null;
public $parameters = []; public $parameters = [];
@@ -44,15 +50,36 @@ class DynamicConfigurations extends Component
return handleError($e, $this); return handleError($e, $this);
} }
$proxy_path = $this->server->proxyPath(); $proxy_path = $this->server->proxyPath();
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic"], $this->server); $fileLimit = self::MAX_CONFIGURATION_FILES + 1;
$files = instant_remote_process(["mkdir -p $proxy_path/dynamic && ls -1 {$proxy_path}/dynamic | head -n {$fileLimit}"], $this->server);
$files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file)); $files = collect(explode("\n", $files))->filter(fn ($file) => ! empty($file));
$files = $files->map(fn ($file) => trim($file)); $files = $files->map(fn ($file) => trim($file));
$files = $files->sort(); $files = $files->sort();
$contents = collect([]); $contents = collect([]);
foreach ($files as $file) { $skippedFiles = collect([]);
$totalBytes = 0;
if ($files->count() > self::MAX_CONFIGURATION_FILES) {
$skippedFiles->push('additional files');
}
foreach ($files->take(self::MAX_CONFIGURATION_FILES) as $file) {
$without_extension = str_replace('.', '|', $file); $without_extension = str_replace('.', '|', $file);
$content = instant_remote_process(["cat {$proxy_path}/dynamic/{$file}"], $this->server); $filePath = escapeshellarg("{$proxy_path}/dynamic/{$file}");
$contents[$without_extension] = $content ?? ''; $readLimit = self::MAX_CONFIGURATION_FILE_SIZE_BYTES + 1;
$content = instant_remote_process(["head -c {$readLimit} {$filePath}"], $this->server);
$content = $content ?? '';
$contentBytes = strlen($content);
if ($contentBytes > self::MAX_CONFIGURATION_FILE_SIZE_BYTES || $totalBytes + $contentBytes > self::MAX_TOTAL_CONFIGURATION_SIZE_BYTES) {
$skippedFiles->push($file);
continue;
}
$contents[$without_extension] = $content;
$totalBytes += $contentBytes;
}
if ($skippedFiles->isNotEmpty()) {
$this->dispatch('warning', 'Some dynamic configurations were not loaded because they exceed the safe display limits: '.$skippedFiles->implode(', '));
} }
$this->contents = $contents; $this->contents = $contents;
$this->dispatch('$refresh'); $this->dispatch('$refresh');
+1 -1
View File
@@ -146,7 +146,7 @@ class Sentinel extends Component
{ {
try { try {
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Sentinel settings updated.'); $this->dispatch('success', 'Sentinel settings updated. Restarting Sentinel.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
+6 -1
View File
@@ -20,6 +20,9 @@ class Index extends Component
#[Validate('nullable|string|max:255|url')] #[Validate('nullable|string|max:255|url')]
public ?string $fqdn = null; public ?string $fqdn = null;
#[Validate('boolean')]
public bool $is_dashboard_force_https_enabled = true;
#[Validate('required|integer|min:1025|max:65535')] #[Validate('required|integer|min:1025|max:65535')]
public int $public_port_min; public int $public_port_min;
@@ -68,6 +71,7 @@ class Index extends Component
$this->server = Server::findOrFail(0); $this->server = Server::findOrFail(0);
} }
$this->fqdn = $this->settings->fqdn; $this->fqdn = $this->settings->fqdn;
$this->is_dashboard_force_https_enabled = $this->settings->is_dashboard_force_https_enabled;
$this->public_port_min = $this->settings->public_port_min; $this->public_port_min = $this->settings->public_port_min;
$this->public_port_max = $this->settings->public_port_max; $this->public_port_max = $this->settings->public_port_max;
$this->instance_name = $this->settings->instance_name; $this->instance_name = $this->settings->instance_name;
@@ -91,6 +95,7 @@ class Index extends Component
$this->authorize('update', $this->settings); $this->authorize('update', $this->settings);
$this->validate(); $this->validate();
$this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn; $this->settings->fqdn = $this->fqdn ? trim($this->fqdn) : $this->fqdn;
$this->settings->is_dashboard_force_https_enabled = $this->is_dashboard_force_https_enabled;
$this->settings->public_port_min = $this->public_port_min; $this->settings->public_port_min = $this->public_port_min;
$this->settings->public_port_max = $this->public_port_max; $this->settings->public_port_max = $this->public_port_max;
$this->settings->instance_name = $this->instance_name; $this->settings->instance_name = $this->instance_name;
@@ -207,7 +212,7 @@ class Index extends Component
return; return;
} }
$imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}"); $imageRef = escapeshellarg(coolifyHelperImage().":{$version}");
$buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile ."; $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process( $activity = remote_process(
+18
View File
@@ -5,6 +5,7 @@ namespace App\Livewire;
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Team; use App\Models\Team;
use App\Notifications\TransactionalEmails\Test; use App\Notifications\TransactionalEmails\Test;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@@ -50,6 +51,9 @@ class SettingsEmail extends Component
#[Validate(['nullable', 'numeric'])] #[Validate(['nullable', 'numeric'])]
public ?string $smtpTimeout = null; public ?string $smtpTimeout = null;
#[Validate(['nullable', 'string'])]
public ?string $smtpEhloDomain = null;
#[Validate(['boolean'])] #[Validate(['boolean'])]
public bool $resendEnabled = false; public bool $resendEnabled = false;
@@ -74,6 +78,7 @@ class SettingsEmail extends Component
{ {
if ($toModel) { if ($toModel) {
$this->validate(); $this->validate();
$this->validate(['smtpEhloDomain' => ['nullable', 'string', new ValidHostname]]);
$this->settings->smtp_enabled = $this->smtpEnabled; $this->settings->smtp_enabled = $this->smtpEnabled;
$this->settings->smtp_host = $this->smtpHost; $this->settings->smtp_host = $this->smtpHost;
$this->settings->smtp_port = $this->smtpPort; $this->settings->smtp_port = $this->smtpPort;
@@ -81,6 +86,7 @@ class SettingsEmail extends Component
$this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName; $this->settings->smtp_from_name = $this->smtpFromName;
@@ -95,6 +101,7 @@ class SettingsEmail extends Component
$this->smtpUsername = $this->settings->smtp_username; $this->smtpUsername = $this->settings->smtp_username;
$this->smtpPassword = $this->settings->smtp_password; $this->smtpPassword = $this->settings->smtp_password;
$this->smtpTimeout = $this->settings->smtp_timeout; $this->smtpTimeout = $this->settings->smtp_timeout;
$this->smtpEhloDomain = $this->settings->smtp_ehlo_domain;
$this->smtpFromAddress = $this->settings->smtp_from_address; $this->smtpFromAddress = $this->settings->smtp_from_address;
$this->smtpFromName = $this->settings->smtp_from_name; $this->smtpFromName = $this->settings->smtp_from_name;
@@ -167,6 +174,7 @@ class SettingsEmail extends Component
'smtpUsername' => 'nullable|string', 'smtpUsername' => 'nullable|string',
'smtpPassword' => 'nullable|string', 'smtpPassword' => 'nullable|string',
'smtpTimeout' => 'nullable|numeric', 'smtpTimeout' => 'nullable|numeric',
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
], [ ], [
'smtpFromAddress.required' => 'From Address is required.', 'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.', 'smtpFromAddress.email' => 'Please enter a valid email address.',
@@ -184,6 +192,7 @@ class SettingsEmail extends Component
$this->settings->smtp_username = $this->smtpUsername; $this->settings->smtp_username = $this->smtpUsername;
$this->settings->smtp_password = $this->smtpPassword; $this->settings->smtp_password = $this->smtpPassword;
$this->settings->smtp_timeout = $this->smtpTimeout; $this->settings->smtp_timeout = $this->smtpTimeout;
$this->settings->smtp_ehlo_domain = $this->smtpEhloDomain;
$this->settings->smtp_from_address = $this->smtpFromAddress; $this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName; $this->settings->smtp_from_name = $this->smtpFromName;
@@ -234,11 +243,20 @@ class SettingsEmail extends Component
$this->authorize('update', $this->settings); $this->authorize('update', $this->settings);
$this->validate([ $this->validate([
'testEmailAddress' => 'required|email', 'testEmailAddress' => 'required|email',
'smtpFromAddress' => 'required|email',
'smtpFromName' => 'required|string',
], [ ], [
'testEmailAddress.required' => 'Test email address is required.', 'testEmailAddress.required' => 'Test email address is required.',
'testEmailAddress.email' => 'Please enter a valid email address.', 'testEmailAddress.email' => 'Please enter a valid email address.',
'smtpFromAddress.required' => 'From Address is required.',
'smtpFromAddress.email' => 'Please enter a valid email address.',
'smtpFromName.required' => 'From Name is required.',
]); ]);
$this->settings->smtp_from_address = $this->smtpFromAddress;
$this->settings->smtp_from_name = $this->smtpFromName;
$this->settings->save();
$executed = RateLimiter::attempt( $executed = RateLimiter::attempt(
'test-email:'.$this->team->id, 'test-email:'.$this->team->id,
$perMinute = 0, $perMinute = 0,
+8 -2
View File
@@ -19,7 +19,7 @@ class SwitchTeam extends Component
$this->switch_to($this->selectedTeamId); $this->switch_to($this->selectedTeamId);
} }
public function switch_to($team_id) public function switch_to($team_id, ?string $currentUrl = null)
{ {
if (! auth()->user()->teams->contains($team_id)) { if (! auth()->user()->teams->contains($team_id)) {
return; return;
@@ -30,6 +30,12 @@ class SwitchTeam extends Component
} }
refreshSession($team_to_switch_to); refreshSession($team_to_switch_to);
return redirect('dashboard'); $parsedUrl = parse_url($currentUrl ?? '/dashboard');
$redirectUrl = data_get($parsedUrl, 'path', '/dashboard');
if ($query = data_get($parsedUrl, 'query')) {
$redirectUrl .= '?'.$query;
}
return redirect($redirectUrl);
} }
} }
+1 -1
View File
@@ -35,7 +35,7 @@ class Create extends Component
'personal_team' => false, 'personal_team' => false,
'is_mcp_server_enabled' => true, 'is_mcp_server_enabled' => true,
]); ]);
auth()->user()->teams()->attach($team, ['role' => 'admin']); auth()->user()->teams()->attach($team, ['role' => 'owner']);
refreshSession($team); refreshSession($team);
return redirectRoute($this, 'team.index'); return redirectRoute($this, 'team.index');
+8 -19
View File
@@ -2,11 +2,9 @@
namespace App\Livewire\Team; namespace App\Livewire\Team;
use App\Actions\Team\DeleteTeam;
use App\Models\Team; use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class DangerZone extends Component class DangerZone extends Component
@@ -25,22 +23,7 @@ class DangerZone extends Component
try { try {
$currentTeam = currentTeam(); $currentTeam = currentTeam();
$this->authorize('delete', $currentTeam); $this->authorize('delete', $currentTeam);
$currentTeam->members->each(function ($user) use ($currentTeam): void { $newTeam = app(DeleteTeam::class)->handle($currentTeam, auth()->user());
if ($user->id === Auth::id()) {
return;
}
$user->teams()->detach($currentTeam);
$session = DB::table('sessions')->where('user_id', $user->id)->first();
if ($session) {
DB::table('sessions')->where('id', $session->id)->delete();
}
});
Cache::forget('user:'.Auth::id().':team:'.$currentTeam->id);
$currentTeam->delete();
$newTeam = Auth::user()->teams()->first();
refreshSession($newTeam); refreshSession($newTeam);
return redirect()->route('team.index'); return redirect()->route('team.index');
@@ -49,6 +32,12 @@ class DangerZone extends Component
} }
} }
public function refreshResources(): void
{
$this->team = Team::query()->findOrFail($this->team->id);
refreshSession($this->team);
}
public function render(): mixed public function render(): mixed
{ {
return view('livewire.team.danger-zone'); return view('livewire.team.danger-zone');
+8 -5
View File
@@ -5,6 +5,7 @@ namespace App\Livewire\Team;
use App\Models\TeamInvitation; use App\Models\TeamInvitation;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class Invitations extends Component class Invitations extends Component
@@ -21,12 +22,14 @@ class Invitations extends Component
$this->authorize('manageInvitations', currentTeam()); $this->authorize('manageInvitations', currentTeam());
$invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id); $invitation = TeamInvitation::ownedByCurrentTeam()->findOrFail($invitation_id);
$user = User::whereEmail($invitation->email)->first(); DB::transaction(function () use ($invitation): void {
if (filled($user)) { $user = User::whereEmail($invitation->email)->first();
$user->deleteIfNotVerifiedAndForcePasswordReset(); if (filled($user)) {
} $user->deleteIfNotVerifiedAndForcePasswordReset();
}
$invitation->delete(); $invitation->delete();
});
$this->refreshInvitations(); $this->refreshInvitations();
$this->dispatch('success', 'Invitation revoked.'); $this->dispatch('success', 'Invitation revoked.');
} catch (\Exception) { } catch (\Exception) {
+17 -8
View File
@@ -7,6 +7,7 @@ use App\Enums\Role;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
class Member extends Component class Member extends Component
@@ -25,8 +26,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$teamId = currentTeam()->id; $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]); DB::transaction(function () use ($teamId): void {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -43,8 +46,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$teamId = currentTeam()->id; $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]); DB::transaction(function () use ($teamId): void {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -61,8 +66,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$teamId = currentTeam()->id; $teamId = currentTeam()->id;
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]); DB::transaction(function () use ($teamId): void {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->dispatch('reloadWindow'); $this->dispatch('reloadWindow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->dispatch('error', $e->getMessage()); $this->dispatch('error', $e->getMessage());
@@ -79,8 +86,10 @@ class Member extends Component
throw new \Exception('You are not authorized to perform this action.'); throw new \Exception('You are not authorized to perform this action.');
} }
$teamId = currentTeam()->id; $teamId = currentTeam()->id;
$this->member->teams()->detach(currentTeam()); DB::transaction(function () use ($teamId): void {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId); $this->member->teams()->detach($teamId);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
// Clear cache for the removed user - both old and new key formats // Clear cache for the removed user - both old and new key formats
Cache::forget("team:{$this->member->id}"); Cache::forget("team:{$this->member->id}");
Cache::forget("user:{$this->member->id}:team:{$teamId}"); Cache::forget("user:{$this->member->id}:team:{$teamId}");
+1 -1
View File
@@ -47,7 +47,7 @@ class Index extends Component
return [ return [
'name' => data_get($container, 'Names'), 'name' => data_get($container, 'Names'),
'connection_name' => data_get($container, 'Names'), 'connection_name' => data_get($container, 'Names'),
'uuid' => data_get($container, 'Names'), 'uuid' => $server->uuid.':'.data_get($container, 'Names'),
'status' => data_get_str($container, 'State')->lower(), 'status' => data_get_str($container, 'State')->lower(),
'server' => $server, 'server' => $server,
'server_uuid' => $server->uuid, 'server_uuid' => $server->uuid,
+2
View File
@@ -20,6 +20,8 @@ class Upgrade extends Component
public bool $devMode = false; public bool $devMode = false;
public bool $fullButton = false;
protected $listeners = ['updateAvailable' => 'checkUpdate']; protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount() public function mount()
+18 -3
View File
@@ -12,6 +12,7 @@ use App\Traits\HasConfiguration;
use App\Traits\HasMetrics; use App\Traits\HasMetrics;
use App\Traits\HasNoindexDomains; use App\Traits\HasNoindexDomains;
use App\Traits\HasSafeStringAttribute; use App\Traits\HasSafeStringAttribute;
use Database\Factories\ApplicationFactory;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -120,7 +121,12 @@ use Symfony\Component\Yaml\Yaml;
class Application extends BaseModel class Application extends BaseModel
{ {
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes; use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
/** @use HasFactory<ApplicationFactory> */
use HasFactory;
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
private static $parserVersion = '5'; private static $parserVersion = '5';
@@ -2109,6 +2115,9 @@ class Application extends BaseModel
$workdir = rtrim($this->base_directory, '/'); $workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location; $composeFile = $this->docker_compose_location;
$fileList = collect([".$workdir$composeFile"]); $fileList = collect([".$workdir$composeFile"]);
$composeFilePath = escapeshellarg(".$workdir$composeFile");
$composeReadLimit = self::MAX_DOCKER_COMPOSE_SIZE_BYTES + 1;
$readComposeFile = "if [ \"$(wc -c < {$composeFilePath})\" -gt ".self::MAX_DOCKER_COMPOSE_SIZE_BYTES." ]; then echo '__COOLIFY_COMPOSE_TOO_LARGE__'; else head -c {$composeReadLimit} {$composeFilePath}; fi";
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
if (! $gitRemoteStatus['is_accessible']) { if (! $gitRemoteStatus['is_accessible']) {
throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.'); throw new RuntimeException('Failed to read Git source. Please verify repository access and try again.');
@@ -2139,7 +2148,7 @@ class Application extends BaseModel
'git sparse-checkout init', 'git sparse-checkout init',
"git sparse-checkout set {$fileList->implode(' ')}", "git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD', 'git read-tree -mu HEAD',
"cat .$workdir$composeFile", $readComposeFile,
]); ]);
} else { } else {
$commands = collect([ $commands = collect([
@@ -2151,11 +2160,14 @@ class Application extends BaseModel
'git sparse-checkout init --cone', 'git sparse-checkout init --cone',
"git sparse-checkout set {$fileList->implode(' ')}", "git sparse-checkout set {$fileList->implode(' ')}",
'git read-tree -mu HEAD', 'git read-tree -mu HEAD',
"cat .$workdir$composeFile", $readComposeFile,
]); ]);
} }
try { try {
$composeFileContent = instant_remote_process($commands, $this->destination->server); $composeFileContent = instant_remote_process($commands, $this->destination->server);
if ($composeFileContent === '__COOLIFY_COMPOSE_TOO_LARGE__' || strlen($composeFileContent) > self::MAX_DOCKER_COMPOSE_SIZE_BYTES) {
throw new RuntimeException('Docker Compose file exceeds the 5 MiB size limit.');
}
} catch (\Exception $e) { } catch (\Exception $e) {
// Restore original values on failure only // Restore original values on failure only
$this->docker_compose_location = $initialDockerComposeLocation; $this->docker_compose_location = $initialDockerComposeLocation;
@@ -2171,6 +2183,9 @@ class Application extends BaseModel
} }
throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.'); throw new RuntimeException('Repository does not exist. Please check your repository URL and try again.');
} }
if (str($e->getMessage())->contains('exceeds the 5 MiB size limit')) {
throw $e;
}
throw new RuntimeException('Failed to read the Docker Compose file from the repository.'); throw new RuntimeException('Failed to read the Docker Compose file from the repository.');
} finally { } finally {
// Cleanup only - restoration happens in catch block // Cleanup only - restoration happens in catch block
+16 -6
View File
@@ -4,6 +4,7 @@ namespace App\Models;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use RuntimeException;
use Spatie\Url\Url; use Spatie\Url\Url;
class ApplicationPreview extends BaseModel class ApplicationPreview extends BaseModel
@@ -28,9 +29,9 @@ class ApplicationPreview extends BaseModel
'pull_request_id' => 'integer', 'pull_request_id' => 'integer',
]; ];
protected static function booted() protected static function booted(): void
{ {
static::forceDeleting(function ($preview) { static::forceDeleting(function (ApplicationPreview $preview): void {
$server = $preview->application->destination->server; $server = $preview->application->destination->server;
$application = $preview->application; $application = $preview->application;
@@ -57,10 +58,19 @@ class ApplicationPreview extends BaseModel
}); });
} else { } else {
// Regular application volume cleanup // Regular application volume cleanup
$persistentStorages = $preview->persistentStorages()->get() ?? collect(); $persistentStorages = $application->persistentStorages()
if ($persistentStorages->count() > 0) { ->get()
foreach ($persistentStorages as $storage) { ->filter(fn (LocalPersistentVolume $storage): bool => blank($storage->host_path)
instant_remote_process(['docker volume rm -f '.escapeshellarg($storage->name)], $server, false); && $storage->is_preview_suffix_enabled);
foreach ($persistentStorages as $storage) {
$volumeName = addPreviewDeploymentSuffix($storage->name, $preview->pull_request_id);
try {
instant_remote_process(['docker volume rm -f '.escapeshellarg($volumeName)], $server);
} catch (RuntimeException $exception) {
if (! preg_match('/\bvolume\b.*\bnot found\b/i', $exception->getMessage())) {
throw $exception;
}
} }
} }
} }
+1
View File
@@ -21,6 +21,7 @@ class EmailNotificationSettings extends Model
'smtp_username', 'smtp_username',
'smtp_password', 'smtp_password',
'smtp_timeout', 'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled', 'resend_enabled',
'resend_api_key', 'resend_api_key',
+6
View File
@@ -3,9 +3,15 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\DB;
class GithubApp extends BaseModel class GithubApp extends BaseModel
{ {
public function delete(): ?bool
{
return DB::transaction(fn () => parent::delete());
}
protected $fillable = [ protected $fillable = [
'team_id', 'team_id',
'private_key_id', 'private_key_id',
+1
View File
@@ -100,6 +100,7 @@ class GitlabApp extends BaseModel
if ($gitlabApp->applications()->count() > 0) { if ($gitlabApp->applications()->count() > 0) {
throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); throw new \RuntimeException('This source is being used by an application. Please delete all applications first.');
} }
}); });
} }
+7
View File
@@ -9,6 +9,10 @@ use Spatie\Url\Url;
class InstanceSettings extends Model class InstanceSettings extends Model
{ {
protected $attributes = [
'is_dashboard_force_https_enabled' => true,
];
protected $fillable = [ protected $fillable = [
'public_ipv4', 'public_ipv4',
'public_ipv6', 'public_ipv6',
@@ -29,6 +33,7 @@ class InstanceSettings extends Model
'smtp_username', 'smtp_username',
'smtp_password', 'smtp_password',
'smtp_timeout', 'smtp_timeout',
'smtp_ehlo_domain',
'resend_enabled', 'resend_enabled',
'resend_api_key', 'resend_api_key',
'is_dns_validation_enabled', 'is_dns_validation_enabled',
@@ -51,6 +56,7 @@ class InstanceSettings extends Model
'webhook_allow_localhost', 'webhook_allow_localhost',
'avatar_storage_type', 'avatar_storage_type',
'avatar_s3_storage_id', 'avatar_s3_storage_id',
'is_dashboard_force_https_enabled',
]; ];
protected $hidden = [ protected $hidden = [
@@ -89,6 +95,7 @@ class InstanceSettings extends Model
'is_mcp_server_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array', 'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean', 'webhook_allow_localhost' => 'boolean',
'is_dashboard_force_https_enabled' => 'boolean',
]; ];
protected static function booted(): void protected static function booted(): void
+24 -3
View File
@@ -138,9 +138,9 @@ class LocalFileVolume extends BaseModel
return; return;
} }
$content = instant_remote_process(["cat {$escapedPath}"], $server, false); $content = $this->readRemoteFileContent($escapedPath, $server);
// Check if content contains binary data by looking for null bytes or non-printable characters // 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)) { if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
$content = self::BINARY_PLACEHOLDER; $content = self::BINARY_PLACEHOLDER;
} }
$this->content = $content; $this->content = $content;
@@ -161,6 +161,27 @@ class LocalFileVolume extends BaseModel
return $size > self::MAX_CONTENT_SIZE; return $size > self::MAX_CONTENT_SIZE;
} }
/**
* Cap the remote read itself so a file that grows after the size check
* cannot be fully slurped into PHP memory.
*/
protected function readRemoteFileContent(string $escapedPath, $server): string
{
$readLimit = self::MAX_CONTENT_SIZE + 1;
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
return self::contentFromBoundedRead($content);
}
public static function contentFromBoundedRead(?string $content): string
{
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
return self::TOO_LARGE_PLACEHOLDER;
}
return (string) $content;
}
public function deleteStorageOnServer() public function deleteStorageOnServer()
{ {
if ($this->is_host_file) { if ($this->is_host_file) {
@@ -253,7 +274,7 @@ class LocalFileVolume extends BaseModel
if ($this->remoteFileExceedsLimit($escapedPath, $server)) { if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER; $this->content = self::TOO_LARGE_PLACEHOLDER;
} else { } else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false); $this->content = $this->readRemoteFileContent($escapedPath, $server);
} }
$this->is_directory = false; $this->is_directory = false;
$this->save(); $this->save();
+2
View File
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
class ScheduledVolumeBackup extends BaseModel class ScheduledVolumeBackup extends BaseModel
{ {
public const int DEFAULT_TIMEOUT = 36000;
protected $fillable = [ protected $fillable = [
'uuid', 'uuid',
'backupable_type', 'backupable_type',
+48 -4
View File
@@ -731,9 +731,7 @@ class Server extends BaseModel
]; ];
if ($schema === 'https') { if ($schema === 'https') {
$traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = [ $traefik_dynamic_conf['http']['routers']['coolify-http']['middlewares'] = $this->dashboardHttpMiddlewares($settings);
0 => 'redirect-to-https',
];
$traefik_dynamic_conf['http']['routers']['coolify-https'] = [ $traefik_dynamic_conf['http']['routers']['coolify-https'] = [
'middlewares' => [ 'middlewares' => [
@@ -792,8 +790,9 @@ class Server extends BaseModel
$url = Url::fromString($settings->fqdn); $url = Url::fromString($settings->fqdn);
$host = $url->getHost(); $host = $url->getHost();
$schema = $url->getScheme(); $schema = $url->getScheme();
$siteAddress = $this->dashboardCaddySiteAddress($settings, $schema, $host);
$caddy_file = " $caddy_file = "
$schema://$host { $siteAddress {
encode zstd gzip encode zstd gzip
handle /app/* { handle /app/* {
reverse_proxy coolify-realtime:6001 reverse_proxy coolify-realtime:6001
@@ -819,6 +818,24 @@ $schema://$host {
], $this); ], $this);
} }
public function dashboardHttpMiddlewares(InstanceSettings $settings): array
{
if ($settings->is_dashboard_force_https_enabled) {
return ['redirect-to-https'];
}
return ['gzip'];
}
public function dashboardCaddySiteAddress(InstanceSettings $settings, string $schema, string $host): string
{
if ($schema === 'https' && ! $settings->is_dashboard_force_https_enabled) {
return "http://{$host}, https://{$host}";
}
return "{$schema}://{$host}";
}
public function proxyPath() public function proxyPath()
{ {
$base_path = config('constants.coolify.base_config_path'); $base_path = config('constants.coolify.base_config_path');
@@ -841,6 +858,33 @@ $schema://$host {
return data_get($this->proxy, 'type'); return data_get($this->proxy, 'type');
} }
public function hasPendingProxyConfiguration(): bool
{
if ($this->proxy->get('status') !== 'running') {
return false;
}
$savedSettings = $this->proxy->get('last_saved_settings');
$appliedSettings = $this->proxy->get('last_applied_settings');
return filled($savedSettings) && filled($appliedSettings) && $savedSettings !== $appliedSettings;
}
public function hasCurrentTraefikOutdatedInfo(): bool
{
if ($this->proxyType() !== ProxyTypes::TRAEFIK->value) {
return false;
}
$detectedVersion = ltrim((string) $this->detected_traefik_version, 'v');
$storedVersion = ltrim((string) data_get($this->traefik_outdated_info, 'current'), 'v');
$type = data_get($this->traefik_outdated_info, 'type');
return filled($detectedVersion)
&& $storedVersion === $detectedVersion
&& in_array($type, ['patch_update', 'minor_upgrade'], true);
}
public function scopeWithProxy(): Builder public function scopeWithProxy(): Builder
{ {
return $this->proxy->modelScope(); return $this->proxy->modelScope();
+7 -4
View File
@@ -33,11 +33,9 @@ class TeamInvitation extends Model
return TeamInvitation::whereTeamId(currentTeam()->id); return TeamInvitation::whereTeamId(currentTeam()->id);
} }
public function isValid() public function isValid(): bool
{ {
$createdAt = $this->created_at; if (! $this->hasExpired()) {
$diff = $createdAt->diffInDays(now());
if ($diff <= config('constants.invitation.link.expiration_days')) {
return true; return true;
} else { } else {
$this->delete(); $this->delete();
@@ -49,4 +47,9 @@ class TeamInvitation extends Model
return false; return false;
} }
} }
public function hasExpired(): bool
{
return $this->created_at->diffInDays(now()) > config('constants.invitation.link.expiration_days');
}
} }
+16 -27
View File
@@ -4,9 +4,14 @@ namespace App\Notifications\Channels;
use App\Exceptions\NonReportableException; use App\Exceptions\NonReportableException;
use App\Models\Team; use App\Models\Team;
use App\Support\SmtpTransportFactory;
use Exception; use Exception;
use Illuminate\Notifications\Notification; use Illuminate\Notifications\Notification;
use Resend; use Resend;
use Resend\Exceptions\ErrorException;
use Resend\Exceptions\TransporterException;
use Symfony\Component\Mailer\Mailer;
use Symfony\Component\Mime\Email;
class EmailChannel class EmailChannel
{ {
@@ -70,43 +75,27 @@ class EmailChannel
if ($isResendEnabled) { if ($isResendEnabled) {
$resend = Resend::client($settings->resend_api_key); $resend = Resend::client($settings->resend_api_key);
$from = "{$settings->smtp_from_name} <{$settings->smtp_from_address}>";
$resend->emails->send([ $resend->emails->send([
'from' => $from, 'from' => mail_from_formatted($settings),
'to' => $recipients, 'to' => $recipients,
'subject' => $mailMessage->subject, 'subject' => $mailMessage->subject,
'html' => (string) $mailMessage->render(), 'html' => (string) $mailMessage->render(),
]); ]);
} elseif ($isSmtpEnabled) { } elseif ($isSmtpEnabled) {
$encryption = match (strtolower($settings->smtp_encryption)) { $transport = SmtpTransportFactory::fromSettings(
'starttls' => null, $settings,
'tls' => 'tls', config('mail.mailers.smtp.local_domain')
'none' => null,
default => null,
};
$transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport(
$settings->smtp_host,
$settings->smtp_port,
$encryption
); );
$transport->setUsername($settings->smtp_username ?? ''); $mailer = new Mailer($transport);
$transport->setPassword($settings->smtp_password ?? '');
$mailer = new \Symfony\Component\Mailer\Mailer($transport); $email = mail_from_email(new Email, $settings)
$fromEmail = $settings->smtp_from_address ?? 'noreply@localhost';
$fromName = $settings->smtp_from_name ?? 'System';
$from = new \Symfony\Component\Mime\Address($fromEmail, $fromName);
$email = (new \Symfony\Component\Mime\Email)
->from($from)
->to(...$recipients) ->to(...$recipients)
->subject($mailMessage->subject) ->subject($mailMessage->subject)
->html((string) $mailMessage->render()); ->html((string) $mailMessage->render());
$mailer->send($email); $mailer->send($email);
} }
} catch (\Resend\Exceptions\ErrorException $e) { } catch (ErrorException $e) {
// Map HTTP status codes to user-friendly messages // Map HTTP status codes to user-friendly messages
$userMessage = match ($e->getErrorCode()) { $userMessage = match ($e->getErrorCode()) {
403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.',
@@ -131,13 +120,13 @@ class EmailChannel
// Don't report expected errors (invalid keys, validation) to Sentry // Don't report expected errors (invalid keys, validation) to Sentry
if (in_array($e->getErrorCode(), [403, 401, 400])) { if (in_array($e->getErrorCode(), [403, 401, 400])) {
throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e));
} }
throw new \Exception($userMessage, $e->getCode(), $e); throw new Exception($userMessage, $e->getCode(), $e);
} catch (\Resend\Exceptions\TransporterException $e) { } catch (TransporterException $e) {
send_internal_notification("Resend Transport Error: {$e->getMessage()}"); send_internal_notification("Resend Transport Error: {$e->getMessage()}");
throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.');
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Check if this is a Resend domain verification error on cloud instances // Check if this is a Resend domain verification error on cloud instances
if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) {
@@ -30,7 +30,7 @@ class TransactionalEmailChannel
Mail::send( Mail::send(
[], [],
[], [],
fn (Message $message) => $message fn (Message $message) => mail_from_message($message, $settings)
->to($email) ->to($email)
->subject($mailMessage->subject) ->subject($mailMessage->subject)
->html((string) $mailMessage->render()) ->html((string) $mailMessage->render())
@@ -54,7 +54,10 @@ class ResetPassword extends Notification
protected function buildMailMessage($url) protected function buildMailMessage($url)
{ {
$from = mail_from_identity($this->settings);
$mail = new MailMessage; $mail = new MailMessage;
$mail->from($from['address'], $from['name']);
$mail->withSymfonyMessage(fn ($message) => prevent_mail_from_header_folding($message, $this->settings));
$mail->subject('Coolify: Reset Password'); $mail->subject('Coolify: Reset Password');
$mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]); $mail->view('emails.reset-password', ['url' => $url, 'count' => config('auth.passwords.'.config('auth.defaults.passwords').'.expire')]);
+18 -3
View File
@@ -20,7 +20,7 @@ class ApiTokenPolicy
*/ */
public function view(User $user, PersonalAccessToken $token): bool public function view(User $user, PersonalAccessToken $token): bool
{ {
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; return $this->belongsToUserAndCurrentTeam($user, $token);
} }
/** /**
@@ -36,7 +36,7 @@ class ApiTokenPolicy
*/ */
public function update(User $user, PersonalAccessToken $token): bool public function update(User $user, PersonalAccessToken $token): bool
{ {
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; return $this->belongsToUserAndCurrentTeam($user, $token);
} }
/** /**
@@ -44,7 +44,7 @@ class ApiTokenPolicy
*/ */
public function delete(User $user, PersonalAccessToken $token): bool public function delete(User $user, PersonalAccessToken $token): bool
{ {
return $user->id === $token->tokenable_id && $token->tokenable_type === User::class; return $this->belongsToUserAndCurrentTeam($user, $token);
} }
/** /**
@@ -86,4 +86,19 @@ class ApiTokenPolicy
{ {
return $user->isAdmin() || $user->isOwner(); return $user->isAdmin() || $user->isOwner();
} }
private function belongsToUserAndCurrentTeam(User $user, PersonalAccessToken $token): bool
{
if ($user->id !== $token->tokenable_id || $token->tokenable_type !== User::class) {
return false;
}
$currentTeamId = $user->currentTeam()?->id;
if ($currentTeamId === null || $token->team_id === null) {
return false;
}
return (string) $currentTeamId === (string) $token->team_id;
}
} }
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Policies;
use App\Models\ScheduledTask;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class ScheduledTaskPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, ScheduledTask $scheduledTask): bool
{
return $user->teams->contains('id', $scheduledTask->team_id);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, ScheduledTask $scheduledTask): Response
{
if (! $user->isAdminOfTeam($scheduledTask->team_id)) {
return Response::deny('You need at least admin or owner permissions to update this scheduled task.');
}
return Response::allow();
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, ScheduledTask $scheduledTask): bool
{
return $user->isAdminOfTeam($scheduledTask->team_id);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, ScheduledTask $scheduledTask): bool
{
return false;
}
}
+1 -1
View File
@@ -53,7 +53,7 @@ class TeamPolicy
return false; return false;
} }
return $user->isAdminOfTeam($team->id); return $user->roleInTeam($team->id) === 'owner';
} }
/** /**
+5
View File
@@ -19,6 +19,7 @@ use App\Models\PrivateKey;
use App\Models\Project; use App\Models\Project;
use App\Models\PushoverNotificationSettings; use App\Models\PushoverNotificationSettings;
use App\Models\S3Storage; use App\Models\S3Storage;
use App\Models\ScheduledTask;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
@@ -56,6 +57,7 @@ use App\Policies\PrivateKeyPolicy;
use App\Policies\ProjectPolicy; use App\Policies\ProjectPolicy;
use App\Policies\ResourceCreatePolicy; use App\Policies\ResourceCreatePolicy;
use App\Policies\S3StoragePolicy; use App\Policies\S3StoragePolicy;
use App\Policies\ScheduledTaskPolicy;
use App\Policies\ServerPolicy; use App\Policies\ServerPolicy;
use App\Policies\ServiceApplicationPolicy; use App\Policies\ServiceApplicationPolicy;
use App\Policies\ServiceDatabasePolicy; use App\Policies\ServiceDatabasePolicy;
@@ -118,6 +120,9 @@ class AuthServiceProvider extends ServiceProvider
// S3 storage policy // S3 storage policy
S3Storage::class => S3StoragePolicy::class, S3Storage::class => S3StoragePolicy::class,
// Scheduled task policy
ScheduledTask::class => ScheduledTaskPolicy::class,
// Team policy // Team policy
Team::class => TeamPolicy::class, Team::class => TeamPolicy::class,
+7
View File
@@ -152,6 +152,13 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($email.'|'.$realIp); return Limit::perMinute(5)->by($email.'|'.$realIp);
}); });
RateLimiter::for('magic-link', function (Request $request) {
$realIp = $request->server('REMOTE_ADDR') ?? $request->ip();
$token = (string) $request->input('token');
return Limit::perMinute(5)->by(hash('sha256', $token.'|'.$realIp));
});
RateLimiter::for('two-factor', function (Request $request) { RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id')); return Limit::perMinute(5)->by($request->session()->get('login.id'));
}); });
+9 -5
View File
@@ -75,12 +75,16 @@ class HorizonServiceProvider extends HorizonApplicationServiceProvider
protected function gate(): void protected function gate(): void
{ {
Gate::define('viewHorizon', function ($user) { Gate::define('viewHorizon', function (User $user) {
$root_user = User::find(0); if ($user->id === 0) {
return true;
}
return in_array($user->email, [ return str(config()->string('horizon.allowed_emails'))
$root_user->email, ->lower()
]); ->explode(',')
->map(fn (string $email) => trim($email))
->contains($user->email);
}); });
} }
} }
+26 -13
View File
@@ -2,7 +2,9 @@
namespace App\Services; namespace App\Services;
use App\Support\SmtpTransportFactory;
use Illuminate\Config\Repository; use Illuminate\Config\Repository;
use Illuminate\Support\Facades\Mail;
class ConfigurationRepository class ConfigurationRepository
{ {
@@ -15,40 +17,51 @@ class ConfigurationRepository
public function updateMailConfig($settings): void public function updateMailConfig($settings): void
{ {
$from = mail_from_identity($settings);
if ($settings->resend_enabled) { if ($settings->resend_enabled) {
$this->config->set('mail.default', 'resend'); $this->config->set('mail.default', 'resend');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); $this->applyMailFrom($from);
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->config->set('resend.api_key', $settings->resend_api_key); $this->config->set('resend.api_key', $settings->resend_api_key);
return; return;
} }
if ($settings->smtp_enabled) { if ($settings->smtp_enabled) {
$encryption = match (strtolower($settings->smtp_encryption)) { $mailerOptions = SmtpTransportFactory::mailerOptions($settings);
'starttls' => null, $localDomain = $settings->smtp_ehlo_domain
'tls' => 'tls', ?? $this->config->get('mail.mailers.smtp.local_domain');
'none' => null,
default => null,
};
$this->config->set('mail.default', 'smtp'); $this->config->set('mail.default', 'smtp');
$this->config->set('mail.from.address', $settings->smtp_from_address ?? 'test@example.com'); $this->applyMailFrom($from);
$this->config->set('mail.from.name', $settings->smtp_from_name ?? 'Test');
$this->config->set('mail.mailers.smtp', [ $this->config->set('mail.mailers.smtp', [
'transport' => 'smtp', 'transport' => 'smtp',
'scheme' => $mailerOptions['scheme'],
'host' => $settings->smtp_host, 'host' => $settings->smtp_host,
'port' => $settings->smtp_port, 'port' => $settings->smtp_port,
'encryption' => $encryption, 'encryption' => $mailerOptions['encryption'],
'username' => $settings->smtp_username, 'username' => $settings->smtp_username,
'password' => $settings->smtp_password, 'password' => $settings->smtp_password,
'timeout' => $settings->smtp_timeout, 'timeout' => $settings->smtp_timeout,
'local_domain' => null, 'local_domain' => $localDomain,
'auto_tls' => $settings->smtp_encryption === 'none' ? '0' : '', 'auto_tls' => $mailerOptions['auto_tls'],
]); ]);
} }
} }
/**
* @param array{address: string, name: string} $from
*/
private function applyMailFrom(array $from): void
{
$this->config->set('mail.from.address', $from['address']);
$this->config->set('mail.from.name', $from['name']);
if (app()->bound('mail.manager')) {
Mail::purge();
}
}
public function disableSshMux(): void public function disableSshMux(): void
{ {
$this->config->set('constants.ssh.mux_enabled', false); $this->config->set('constants.ssh.mux_enabled', false);
@@ -17,28 +17,8 @@ class ConfigurationDiffer
*/ */
private const IGNORED_KEYS = ['build.docker_compose']; private const IGNORED_KEYS = ['build.docker_compose'];
/** /** @var array<int, string> */
* Defaults for fields introduced after configuration snapshots were first private const DYNAMIC_SECTIONS = ['environment', 'storage'];
* stored. Older snapshots omitted these keys, which should not make an
* unchanged default look like a pending configuration change.
*
* @var array<string, mixed>
*/
private const INTRODUCED_DEFAULTS = [
'build.is_static' => false,
'build.is_spa' => false,
'build.is_git_submodules_enabled' => true,
'build.is_git_lfs_enabled' => true,
'build.is_git_shallow_clone_enabled' => true,
'build.is_env_sorting_enabled' => [false, true],
'runtime.is_consistent_container_name_enabled' => false,
'runtime.is_container_label_escape_enabled' => true,
'runtime.is_container_label_readonly_enabled' => true,
'runtime.is_log_drain_enabled' => false,
'runtime.is_swarm_only_worker_nodes' => true,
'runtime.is_preserve_repository_enabled' => false,
'domains.noindex_domains' => [],
];
/** /**
* @param array<string, mixed> $previousSnapshot * @param array<string, mixed> $previousSnapshot
@@ -59,11 +39,7 @@ class ConfigurationDiffer
$previous = $previousItems[$key] ?? null; $previous = $previousItems[$key] ?? null;
$current = $currentItems[$key] ?? null; $current = $currentItems[$key] ?? null;
if ( if ($previous === null && ! in_array(data_get($current, 'section'), self::DYNAMIC_SECTIONS, true)) {
$previous === null
&& array_key_exists($key, self::INTRODUCED_DEFAULTS)
&& $this->matchesIntroducedDefault($key, data_get($current, 'compare_value'))
) {
continue; continue;
} }
@@ -127,17 +103,6 @@ class ConfigurationDiffer
return ConfigurationDiff::fromChanges($changes); return ConfigurationDiff::fromChanges($changes);
} }
private function matchesIntroducedDefault(string $key, mixed $value): bool
{
$default = self::INTRODUCED_DEFAULTS[$key];
if (is_array($default) && $default !== [] && array_is_list($default)) {
return in_array($value, $default, true);
}
return $value === $default;
}
/** /**
* Reduce two multi-line values to only the lines that differ, so the modal * Reduce two multi-line values to only the lines that differ, so the modal
* shows just the changed container labels instead of the whole block. * shows just the changed container labels instead of the whole block.
+7 -2
View File
@@ -90,8 +90,11 @@ class DatabaseBackupFileValidator
public static function containsPostgresqlProgramExecution(string $sql): bool public static function containsPostgresqlProgramExecution(string $sql): bool
{ {
$requireStatementBoundary = true;
if (str_starts_with($sql, 'PGDMP')) { if (str_starts_with($sql, 'PGDMP')) {
return false; $sql = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F]+/', "\n", $sql) ?? $sql;
$requireStatementBoundary = false;
} }
$withoutComments = self::stripSqlComments($sql); $withoutComments = self::stripSqlComments($sql);
@@ -100,7 +103,9 @@ class DatabaseBackupFileValidator
return true; return true;
} }
return preg_match('/(?:^|;)\s*copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1; $copyPrefix = $requireStatementBoundary ? '(?:^|;)\s*' : '\b';
return preg_match('/'.$copyPrefix.'copy\b[^;]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
} }
private static function extensionFor(string $name): ?string private static function extensionFor(string $name): ?string
+2 -2
View File
@@ -4,11 +4,11 @@ namespace App\Support;
class DomainUrlParts class DomainUrlParts
{ {
public static function compose(string $scheme, string $host, string $port = '', string $path = ''): string public static function compose(string $scheme, string $host, ?string $port = '', string $path = ''): string
{ {
$scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https'; $scheme = strtolower(trim($scheme)) === 'http' ? 'http' : 'https';
$host = trim($host); $host = trim($host);
$port = trim($port); $port = trim((string) $port);
$path = trim($path); $path = trim($path);
if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) { if ($path !== '' && ! str_starts_with($path, '/') && ! str_starts_with($path, '?') && ! str_starts_with($path, '#')) {
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Support;
use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport;
use Symfony\Component\Mailer\Transport\Smtp\Stream\SocketStream;
class SmtpTransportFactory
{
public static function fromSettings(object $settings, ?string $localDomain = null): EsmtpTransport
{
$mode = self::encryptionMode($settings);
$transport = new EsmtpTransport(
$settings->smtp_host,
(int) $settings->smtp_port,
match ($mode) {
'none' => false,
'tls' => true,
default => null,
}
);
if ($mode === 'none') {
$transport->setAutoTls(false);
}
$transport->setUsername($settings->smtp_username ?? '');
$transport->setPassword($settings->smtp_password ?? '');
$localDomain = $settings->smtp_ehlo_domain ?? $localDomain;
if ($localDomain !== null && $localDomain !== '') {
$transport->setLocalDomain($localDomain);
}
$stream = $transport->getStream();
if (isset($settings->smtp_timeout) && $stream instanceof SocketStream) {
$stream->setTimeout((float) $settings->smtp_timeout);
}
return $transport;
}
/**
* @return array{scheme: ?string, encryption: ?string, auto_tls: string}
*/
public static function mailerOptions(object $settings): array
{
$mode = self::encryptionMode($settings);
return [
'scheme' => match ($mode) {
'none', 'starttls' => 'smtp',
'tls' => 'smtps',
default => null,
},
'encryption' => $mode === 'tls' ? 'tls' : null,
'auto_tls' => $mode === 'none' ? '0' : '',
];
}
private static function encryptionMode(object $settings): ?string
{
return $settings->smtp_encryption === null
? null
: strtolower((string) $settings->smtp_encryption);
}
}
Loaded 100 of 249 files, more files were not shown because too many files have changed in this diff. Show more