Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai 2026-07-07 12:56:19 +02:00
commit ff976a134f
136 changed files with 6142 additions and 1733 deletions

View file

@ -27,12 +27,6 @@ DB_PORT=5432
# DB_WRITE_PASSWORD= # DB_WRITE_PASSWORD=
# DB_STICKY=true # DB_STICKY=true
# Ray Configuration
# Set to true to enable Ray
RAY_ENABLED=false
# Set custom ray port
# RAY_PORT=
# Enable Laravel Telescope for debugging # Enable Laravel Telescope for debugging
TELESCOPE_ENABLED=false TELESCOPE_ENABLED=false

View file

@ -15,4 +15,4 @@ ROOT_USERNAME=
ROOT_USER_EMAIL= ROOT_USER_EMAIL=
ROOT_USER_PASSWORD= ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io REGISTRY_URL=docker.io

View file

@ -1,7 +1,7 @@
name: 🐞 Bug Report name: 🐞 Bug Report
description: "File a new bug report." description: "File a new bug report."
title: "[Bug]: " title: "[Bug]: "
labels: ["🐛 Bug", "🔍 Triage"] labels: ["🔍 Triage"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -11,10 +11,22 @@ body:
- type: textarea - type: textarea
attributes: attributes:
label: Error Message and Logs label: Description and Error Message
description: Provide a detailed description of the error or exception you encountered, along with any relevant log output. description: Provide a detailed description of the error or exception you encountered, along with any relevant log output.
validations: validations:
required: true required: true
- type: textarea
attributes:
label: Expected Behavior
description: Please describe what you expected to happen instead of the issue. Be as detailed as possible.
value: |
1.
2.
3.
4.
validations:
required: true
- type: textarea - type: textarea
attributes: attributes:
@ -37,7 +49,7 @@ body:
attributes: attributes:
label: Coolify Version label: Coolify Version
description: Please provide the Coolify version you are using. This can be found in the top left corner of your Coolify dashboard. description: Please provide the Coolify version you are using. This can be found in the top left corner of your Coolify dashboard.
placeholder: "v4.0.0-beta.335" placeholder: "v4.1.2"
validations: validations:
required: true required: true
@ -55,6 +67,12 @@ body:
label: Operating System and Version (self-hosted) label: Operating System and Version (self-hosted)
description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version. description: Run `cat /etc/os-release` or `lsb_release -a` in your terminal and provide the operating system and version.
placeholder: "Ubuntu 22.04" placeholder: "Ubuntu 22.04"
- type: textarea
attributes:
label: Screenshots / Visuals
description: If possible, provide screenshots, screen recordings, or diagrams to help illustrate the issue.
placeholder: "Attach images or provide links to recordings demonstrating the problem."
- type: textarea - type: textarea
attributes: attributes:

View file

@ -1,289 +1,231 @@
# Contributing to Coolify # Contributing to Coolify
Were happy that youre interested in contributing to Coolify!
> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai) There are many ways to help:
- Answer questions in GitHub Discussions or Discord
- Report reproducible bugs
- Submit pull requests to fix issues
- Add new one-click services
- Improve documentation
You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel. Coolify is a PaaS used by 400,000+ people worldwide and maintained by two active maintainers. Contributions are welcome — but **alignment matters more than quantity**.
To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document. This guide explains **what kind of contributions are likely to be accepted** and how to submit them properly. Following it saves time for both you and the maintainers.
## Table of Contents
1. [Setup Development Environment](#1-setup-development-environment)
2. [Verify Installation](#2-verify-installation-optional)
3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository)
4. [Set up Environment Variables](#4-set-up-environment-variables)
5. [Start Coolify](#5-start-coolify)
6. [Start Development](#6-start-development)
7. [Create a Pull Request](#7-create-a-pull-request)
8. [Development Notes](#development-notes)
9. [Resetting Development Environment](#resetting-development-environment)
10. [Additional Contribution Guidelines](#additional-contribution-guidelines)
## 1. Setup Development Environment
Follow the steps below for your operating system:
<details>
<summary><strong>Windows</strong></summary>
1. Install `docker-ce`, Docker Desktop (or similar):
- Docker CE (recommended):
- Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify)
- After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify)
- Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide
- Install Docker Desktop (easier):
- Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify)
- Ensure WSL2 backend is enabled in Docker Desktop settings
2. Install Spin:
- Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify)
</details>
<details>
<summary><strong>MacOS</strong></summary>
1. Install Orbstack, Docker Desktop (or similar):
- Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop):
- Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify)
- Docker Desktop:
- Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify)
</details>
<details>
<summary><strong>Linux</strong></summary>
1. Install Docker Engine, Docker Desktop (or similar):
- Docker Engine (recommended, as there is no VM overhead):
- Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution
- Docker Desktop:
- If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify)
</details>
## 2. Verify Installation (Optional)
After installing Docker (or Orbstack) and Spin, verify the installation:
1. Open a terminal or command prompt
2. Run the following commands:
```bash
docker --version
spin --version
```
You should see version information for both Docker and Spin.
## 3. Fork and Setup Local Repository
1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account.
2. Install a code editor on your machine (choose one):
| Editor | Platform | Download Link |
|--------|----------|---------------|
| Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) |
| Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) |
| Zed (very fast) | macOS/Linux | [Download](https://zed.dev/download?ref=coolify) |
3. Clone the Coolify Repository from your fork to your local machine
- Use `git clone` in the command line, or
- Use GitHub Desktop (recommended):
- Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify)
- Open GitHub Desktop and login with your GitHub account
- Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone`
4. Open the cloned Coolify Repository in your chosen code editor.
## 4. Set up Environment Variables
1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository.
2. Duplicate the `.env.development.example` file and rename the copy to `.env`.
3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup.
4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`.
5. Save the changes to your `.env` file.
## 5. Start Coolify
1. Open a terminal in the local Coolify directory.
2. Run the following command in the terminal (leave that terminal open):
```bash
spin up
```
> [!NOTE]
> You may see some errors, but don't worry; this is expected.
3. If you encounter permission errors, especially on macOS, use:
```bash
sudo spin up
```
> [!NOTE]
> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again.
## 6. Start Development
1. Access your Coolify instance:
- URL: `http://localhost:8000`
- Login: `test@example.com`
- Password: `password`
2. Additional development tools:
| Tool | URL | Note |
|------|-----|------|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
| Mailpit (email catcher) | `http://localhost:8025` | |
| Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default |
> [!NOTE]
> To enable Telescope, add the following to your `.env` file:
> ```env
> TELESCOPE_ENABLED=true
> ```
## 7. Create a Pull Request
> [!IMPORTANT] > [!IMPORTANT]
> Please read the [Pull Request Guidelines](#pull-request-guidelines) carefully before creating your PR. > These guidelines may feel stricter than in many open-source projects. That is intentional.
> Clear structure and boundaries prevent maintainer burnout and keep the project sustainable long-term.
1. After making changes or adding a new service:
- Commit your changes to your forked repository.
- Push the changes to your GitHub account.
2. Creating the Pull Request (PR): ## High-Level Expectations
- Navigate to the main Coolify repository on GitHub. - Coolify has a clear product direction.
- Click the "Pull requests" tab. - Ownership and decisions are centralized.
- Click the green "New pull request" button. - Review capacity is limited.
- Choose your fork and `next` branch as the compare branch. - Not every contribution will be accepted — even if technically correct.
- Click "Create pull request".
3. Filling out the PR details: This is normal for a two-maintainer project.
- Give your PR a descriptive title.
- Use the Pull Request Template provided and fill in the details.
> [!IMPORTANT]
> Always set the base branch for your PR to the `next` branch of the Coolify repository, not the `v4.x` branch.
4. Submit your PR: ## State of the Project
- Review your changes one last time. Coolify is currently at v4. While v4 is stable, it has some limitations, including:
- Click "Create pull request" to submit. - Limited scaling support
- A more complex user experience
- Other smaller issues that need refinement
> [!NOTE] These limitations will be addressed in Coolify v5, which is in the planning stage. Because of this, major features, architectural changes, or significant UI changes will not be accepted for v4 at this stage.
> Make sure your PR is out of draft mode as soon as it's ready for review. PRs that are in draft mode for a long time may be closed by maintainers.
After submission, maintainers will review your PR and may request changes or provide feedback. We welcome contributions that help stabilize v4 for a bug free experience.
#### Pull Request Guidelines
To maintain high-quality contributions and efficient review process:
- **Target Branch**: Always target the `next` branch, never `v4.x` or any other branch. PRs targeting incorrect branches will be closed without review.
- **Descriptive Titles**: Use clear, concise PR titles that describe the change (e.g., "fix: one click postgresql database stuck in restart loop" instead of "Fix database").
- **PR Descriptions**: Provide detailed, meaningful descriptions. Avoid generic or AI-generated fluff. Include:
- What the change does
- Why it's needed
- How to test it
- Any breaking changes
- Screenshot or video recording of your changes working without any issues
- Links to related issues
- **Link to Issues**: All PRs must link to an existing GitHub issue. If no issue exists, create one first. Unrelated PRs may be closed.
- **Single Responsibility**: Each PR should address one issue or feature. Do not bundle unrelated changes.
- **Draft Mode**: Use draft PRs for work-in-progress. Convert to ready-for-review only when complete and tested.
- **Review Readiness**: Ensure your PR is ready for review within a reasonable timeframe (max 7 days in draft). Stale drafts may be closed.
- **Current Focus**: We are currently prioritizing stability and bug fixes over new features. PRs adding new features may not be reviewed, or may be closed without review to maintain focus.
- **Language Translations**: Coolify currently supports only English. Pull requests for new language translations will not be accepted. Multi-language support may be considered in the next major version (v5).
- **AI Usage Policy**: We are not against AI tools—we use them ourselves. However, AI discourse is mandatory: You must fully understand the changes in your PR and be able to explain them clearly. Many PRs using AI lack this understanding, leading to untested or incorrect submissions. If you use AI, ensure you can articulate what the code does, why it was changed, and how it was tested.
#### Review Process ## What Makes a Strong Contribution
- **Response Time**: Maintainers will review PRs promptly, but complex changes may take time. Be patient and responsive to feedback. The following types of contributions are most likely to be accepted:
- **Revisions**: Address all review comments. Unresolved feedback may lead to PR closure.
- **Merge Criteria**: PRs are merged only after:
- All tests pass (including CI)
- Code review approval
- **Closing PRs**: PRs may be closed for:
- Inactivity (>7 days without response)
- Failure to meet guidelines
- Duplicate or superseded work
- Security or quality concerns
#### Code Quality and Testing #### Code Quality and Testing
All contributions must adhere to the highest standards of code quality and testing: All contributions must adhere to the highest standards of code quality and testing:
- **Testing Required**: Every PR must include steps to test your changes. Untested code will not be reviewed or merged. If your change is small and obvious (typo fix, small bug, minor docs update), you may open a pull request directly.
- **Local Verification**: Ensure your changes work in the development environment. Test all affected features thoroughly.
- **Code Standards**: Follow the existing code style, conventions, and patterns in the codebase.
- **No AI-Generated Code**: Do not submit code generated by AI tools without fully understanding and verifying it. AI-generated submissions that are untested or incorrect will be rejected immediately.
## Development Notes If you are fixing a bug in `file.yaml`, do not:
- Reformat unrelated files
- Refactor unrelated code
- Fix style issues elsewhere
- Combine multiple unrelated changes
When working on Coolify, keep the following in mind: Even “improvements” increase review complexity.
1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations: **One pull request = one logical change.**
```bash
docker exec -it coolify php artisan migrate
```
2. **Resetting Development Setup**: To reset your development setup to a clean database with default values: If you want to refactor or clean up code, discuss it first and submit it separately.
```bash
docker exec -it coolify php artisan migrate:fresh --seed
```
3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues.
> [!IMPORTANT] ## Discussion Is Required for Larger Changes
> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches. For anything beyond a small fix, you must discuss it before opening a pull request.
## Resetting Development Environment This includes:
- New features
- UI/UX changes
- Changes to default behavior
- Refactors or cleanup work
- Performance rewrites
- Architectural changes
- Changes touching many files
If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`): Discussion happens in GitHub Discussions: https://github.com/coollabsio/coolify/discussions/categories/general
1. Stop all running containers `ctrl + c`. Pull requests introducing major changes without prior discussion will be closed without review.
2. Remove all Coolify containers: This ensures alignment before significant work is done.
```bash
docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail
```
3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command):
```bash
docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data
```
4. Remove unused images: ## What This Project Is Not
```bash To set clear expectations:
docker image prune -a - Coolify is not optimized for first-time open-source contributors
``` - We do not provide beginner-focused mentorship issues
- Large unsolicited changes are unlikely to be accepted
- Broad refactors or style rewrites are not helpful
- Low-effort AI-generated pull requests will be closed
5. Start Coolify again: AI usage is allowed. However, contributors must fully understand what their changes do and why.
```bash
spin up
```
6. Run database migrations and seeders: Clear expectations help everyone use their time effectively.
```bash
docker exec -it coolify php artisan migrate:fresh --seed
```
After completing these steps, you'll have a fresh development setup.
> [!IMPORTANT] # Ways to Contribute
> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data. ## 1. Support Contributions
We use Discord for most support requests and GitHub Discussions for help.
## Additional Contribution Guidelines ### Requesting Support
If you need help:
- Provide complete and detailed information
- Include logs, screenshots, and steps to reproduce
- Be respectful — support is voluntary
### Contributing a New Service Do not ping people for attention. They respond when available.
To add a new service to Coolify, please refer to our documentation: ### Providing Support
[Adding a New Service](https://coolify.io/docs/get-started/contribute/service) If you help others:
- Verify your information before sharing
- Be patient and respectful
- Remember that not everyone has the same experience level
### Contributing to Documentation
To contribute to the Coolify documentation, please refer to this guide: ## 2. Bug Report Contributions
[Contributing to the Coolify Documentation](https://github.com/coollabsio/documentation-coolify/blob/main/readme.md) Create a GitHub issue **only** if:
- The bug is reproducible
- You have confirmed no existing issue already covers it
For questions or general help, use GitHub Discussions or the Discord support channel.
Bug reports must include:
- Clear reproduction steps
- Expected result
- Actual result
Incomplete reports and reports generated using AI may be closed.
## 3. Code Contributions
Maintainers may close pull requests at their discretion, without explanation.
### Issue Requirement
Every pull request should reference and close an Issue or Discussion.
If none exists, create one first.
Pull requests without linked issue or discussions may not be reviewed and can be closed at any time.
## Commit Message Format
All commits must start with an action and category:
- `fix(ui):` — UI-related fixes
- `feat(api):` — API-related changes
- `feat(service):` — One-click service changes
Examples:
- `fix(api): version endpoint returns wrong data`
- `feat(service): add supabase`
Use the commit description only for concise context.
Walls of text listing every change in description will be rejected.
## Pull Request Title Format
Pull request titles follow the same format:
- `fix(ui):`
- `feat(api):`
- `feat(service):`
Examples:
- `fix(api): version endpoint returns wrong data`
- `feat(service): add supabase`
## AI Usage Disclosure
If AI tools were used at any stage, mention it in the pull request description.
AI is allowed.
However:
- You must understand every change
- You must verify correctness
- You must ensure it follows project patterns
AI-generated pull requests without clear understanding will be closed.
## Test Before Submitting
Before submitting a pull request:
- Manually test your changes thoroughly
- Verify they work in a clean environment
- Provide detailed testing steps in the PR description
If maintainers cannot reproduce working behavior, the PR will be closed without further review.
## Submitting a Pull Request
- GitHub will auto-populate the PR template
- The contributor agreement in PR description must remain intact
- Pull requests without the contributor agreement will be closed
- All pull requests must target the `next` branch
- PRs targeting other branches will be closed without review
## FAQ
**Q: Should I ask before fixing a typo or a small bug?**
A: No, small, obvious fixes like typos or narrowly-scoped bug fixes can be submitted as a PR directly.
**Q: I have an idea for a new feature.**
A: Awesome! Discuss it first in GitHub Discussions or Discord. **Do not** open a PR for new features without prior alignment.
**Q: My PR was closed without detailed feedback.**
A: This usually means it didnt align with the projects direction, required more review bandwidth than available, or targeted major changes not allowed in v4.
**Q: Can I work on an open issue?**
A: Comment on the issue first to confirm its still relevant and that no one else is actively working on it. For anything beyond a small fix, discuss your approach before implementing.
**Q: I noticed code that could be cleaned up while working on my change.**
A: Focus only on your stated goal. Cleanups or refactors should be submitted as separate PRs after discussion.
**Q: Can I use AI to help with my PR?**
A: Yes, AI-assisted contributions are allowed. But you must fully understand and verify the changes. PRs that appear to be generated by AI without context understanding will be closed.
**Q: My PR was closed without review. Can I submit a new one?**
A: Yes, but keep in mind a PR closure is feedback, not a rejection of your effort. It usually means the PR didnt match the project goals or guidelines. Address these issues first — repeating the same approach may hurt your standing with maintainers.
# Development Guides
## Local Development
To build and run Coolify locally, see: [Development](./DEVELOPMENT.md)
### 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.
After creating and starting a Lima VM, run the normal local development commands from inside the VM as described in [Development](./DEVELOPMENT.md).
## Adding a New Service
To add a new one-click service, follow: https://coolify.io/docs/get-started/contribute/service
## Contributing to Documentation
To contribute to documentation, see: https://coolify.io/docs/get-started/contribute/documentation

212
DEVELOPMENT.md Normal file
View file

@ -0,0 +1,212 @@
# Contributing to Coolify
> "First, thanks for considering contributing to my project. It really means a lot!" - [@andrasbacsai](https://github.com/andrasbacsai)
You can ask for guidance anytime on our [Discord server](https://coollabs.io/discord) in the `#contribute` channel.
To understand the tech stack, please refer to the [Tech Stack](TECH_STACK.md) document.
## Table of Contents
1. [Setup Development Environment](#1-setup-development-environment)
2. [Verify Installation](#2-verify-installation-optional)
3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository)
4. [Set up Environment Variables](#4-set-up-environment-variables)
5. [Start Coolify](#5-start-coolify)
6. [Start Development](#6-start-development)
7. [Create a Pull Request](#7-create-a-pull-request)
8. [Development Notes](#development-notes)
9. [Resetting Development Environment](#resetting-development-environment)
10. [Additional Contribution Guidelines](#additional-contribution-guidelines)
## 1. Setup Development Environment
Follow the steps below for your operating system:
<details>
<summary><strong>Windows</strong></summary>
1. Install `docker-ce`, Docker Desktop (or similar):
- Docker CE (recommended):
- Install Windows Subsystem for Linux v2 (WSL2) by following this guide: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install?ref=coolify)
- After installing WSL2, install Docker CE for your Linux distribution by following this guide: [Install Docker Engine](https://docs.docker.com/engine/install/?ref=coolify)
- Make sure to choose the appropriate Linux distribution (e.g., Ubuntu) when following the Docker installation guide
- Install Docker Desktop (easier):
- Download and install [Docker Desktop for Windows](https://docs.docker.com/desktop/install/windows-install/?ref=coolify)
- Ensure WSL2 backend is enabled in Docker Desktop settings
2. Install Spin:
- Follow the instructions to install Spin on Windows from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-windows#download-and-install-spin-into-wsl2?ref=coolify)
</details>
<details>
<summary><strong>MacOS</strong></summary>
1. Install Orbstack, Docker Desktop (or similar):
- Orbstack (recommended, as it is a faster and lighter alternative to Docker Desktop):
- Download and install [Orbstack](https://docs.orbstack.dev/quick-start#installation?ref=coolify)
- Docker Desktop:
- Download and install [Docker Desktop for Mac](https://docs.docker.com/desktop/install/mac-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on MacOS from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-macos/#download-and-install-spin?ref=coolify)
</details>
<details>
<summary><strong>Linux</strong></summary>
1. Install Docker Engine, Docker Desktop (or similar):
- Docker Engine (recommended, as there is no VM overhead):
- Follow the official [Docker Engine installation guide](https://docs.docker.com/engine/install/?ref=coolify) for your Linux distribution
- Docker Desktop:
- If you want a GUI, you can use [Docker Desktop for Linux](https://docs.docker.com/desktop/install/linux-install/?ref=coolify)
2. Install Spin:
- Follow the instructions to install Spin on Linux from the [Spin documentation](https://serversideup.net/open-source/spin/docs/installation/install-linux#configure-docker-permissions?ref=coolify)
</details>
## 2. Verify Installation (Optional)
After installing Docker (or Orbstack) and Spin, verify the installation:
1. Open a terminal or command prompt
2. Run the following commands:
```bash
docker --version
spin --version
```
You should see version information for both Docker and Spin.
## 3. Fork and Setup Local Repository
1. Fork the [Coolify](https://github.com/coollabsio/coolify) repository to your GitHub account.
2. Install a code editor on your machine (choose one):
| Editor | Platform | Download Link |
|--------|----------|---------------|
| Visual Studio Code (recommended free) | Windows/macOS/Linux | [Download](https://code.visualstudio.com/download?ref=coolify) |
| Cursor (recommended but paid) | Windows/macOS/Linux | [Download](https://www.cursor.com/?ref=coolify) |
| Zed (very fast) | Windows/macOS/Linux | [Download](https://zed.dev/download?ref=coolify) |
3. Clone the Coolify Repository from your fork to your local machine
- Use `git clone` in the command line, or
- Use GitHub Desktop (recommended):
- Download and install from [https://desktop.github.com/](https://desktop.github.com/?ref=coolify)
- Open GitHub Desktop and login with your GitHub account
- Click on `File` -> `Clone Repository` select `github.com` as the repository location, then select your forked Coolify repository, choose the local path and then click `Clone`
4. Open the cloned Coolify Repository in your chosen code editor.
## 4. Set up Environment Variables
1. In the Code Editor, locate the `.env.development.example` file in the root directory of your local Coolify repository.
2. Duplicate the `.env.development.example` file and rename the copy to `.env`.
3. Open the new `.env` file and review its contents. Adjust any environment variables as needed for your development setup.
4. If you encounter errors during database migrations, update the database connection settings in your `.env` file. Use the IP address or hostname of your PostgreSQL database container. You can find this information by running `docker ps` after executing `spin up`.
5. Save the changes to your `.env` file.
## 5. Start Coolify
1. Open a terminal in the local Coolify directory.
2. Run the following command in the terminal (leave that terminal open):
```bash
spin up
```
> [!NOTE]
> You may see some errors, but don't worry; this is expected.
3. If you encounter permission errors, especially on macOS, use:
```bash
sudo spin up
```
> [!NOTE]
> If you change environment variables afterwards or anything seems broken, press Ctrl + C to stop the process and run `spin up` again.
## 6. Start Development
1. Access your Coolify instance:
- URL: `http://localhost:8000`
- Login: `test@example.com`
- Password: `password`
2. Additional development tools:
| Tool | URL | Note |
|------|-----|------|
| Laravel Horizon (scheduler) | `http://localhost:8000/horizon` | Only accessible when logged in as root user |
| Mailpit (email catcher) | `http://localhost:8025` | |
| Telescope (debugging tool) | `http://localhost:8000/telescope` | Disabled by default |
> [!NOTE]
> To enable Telescope, add the following to your `.env` file:
> ```env
> TELESCOPE_ENABLED=true
> ```
## Development Notes
When working on Coolify, keep the following in mind:
1. **Database Migrations**: After switching branches or making changes to the database structure, always run migrations:
```bash
docker exec -it coolify php artisan migrate
```
2. **Resetting Development Setup**: To reset your development setup to a clean database with default values:
```bash
docker exec -it coolify php artisan migrate:fresh --seed
```
3. **Troubleshooting**: If you encounter unexpected behavior, ensure your database is up-to-date with the latest migrations and if possible reset the development setup to eliminate any environment-specific issues.
> [!IMPORTANT]
> Forgetting to migrate the database can cause problems, so make it a habit to run migrations after pulling changes or switching branches.
## Resetting Development Environment
If you encounter issues or break your database or something else, follow these steps to start from a clean slate (works since `v4.0.0-beta.342`):
1. Stop all running containers `ctrl + c`.
2. Remove all Coolify containers:
```bash
docker rm coolify coolify-db coolify-redis coolify-realtime coolify-testing-host coolify-minio coolify-vite-1 coolify-mail
```
3. Remove Coolify volumes (it is possible that the volumes have no `coolify` prefix on your machine, in that case remove the prefix from the command):
```bash
docker volume rm coolify_dev_backups_data coolify_dev_postgres_data coolify_dev_redis_data coolify_dev_coolify_data coolify_dev_minio_data
```
4. Remove unused images:
```bash
docker image prune -a
```
5. Start Coolify again:
```bash
spin up
```
6. Run database migrations and seeders:
```bash
docker exec -it coolify php artisan migrate:fresh --seed
```
After completing these steps, you'll have a fresh development setup.
> [!IMPORTANT]
> Always run database migrations and seeders after switching branches or pulling updates to ensure your local database structure matches the current codebase and includes necessary seed data.
## Additional Development Guidelines
### Adding a New Service
To add a new service to Coolify, please refer to our documentation: [Adding a New Service](https://coolify.io/docs/get-started/contribute/service)
### Development for Documentation
To contribute to the Coolify documentation, please refer to this guide: [Contributing to the Coolify Documentation](https://coolify.io/docs/get-started/contribute/documentation)

View file

@ -143,8 +143,6 @@ public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|St
) )
); );
ray("Database proxy for {$database->name} disabled due to non-transient error: {$e->getMessage()}");
return; return;
} }

View file

@ -20,7 +20,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion"; $realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion(); $helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image'); $helperImage = coolifyHelperImage();
$helperImageWithVersion = "$helperImage:$helperImageVersion"; $helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper'; $helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion"; $helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";

View file

@ -26,22 +26,14 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het
); );
} }
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part // If server is already deleted from Coolify, skip this part
if (! $server) { if (! $server) {
return; // Server already force deleted from Coolify return; // Server already force deleted from Coolify
} }
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try { try {
$server->forceDelete(); $server->forceDelete();
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray('Failed to force delete server from Coolify', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
logger()->error('Failed to force delete server from Coolify', [ logger()->error('Failed to force delete server from Coolify', [
'error' => $e->getMessage(), 'error' => $e->getMessage(),
'server_id' => $server->id, 'server_id' => $server->id,
@ -66,10 +58,6 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
} }
if (! $token) { if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return; return;
} }
@ -77,16 +65,7 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$hetznerService = new HetznerService($token->token); $hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId); $hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Log the error but don't prevent the server from being deleted from Coolify // Log the error but don't prevent the server from being deleted from Coolify
logger()->error('Failed to delete server from Hetzner', [ logger()->error('Failed to delete server from Hetzner', [

View file

@ -26,7 +26,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$endpoint = data_get($server, 'settings.sentinel_custom_url'); $endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled'); $debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel'; $mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version; $image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) { if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.'); throw new \RuntimeException('You should set FQDN in Instance Settings.');
} }

View file

@ -118,10 +118,14 @@ private function update()
{ {
$latestHelperImageVersion = getHelperVersion(); $latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url'); $upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
remote_process([ remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh", "curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion", 'bash /data/coolify/source/upgrade.sh '.
escapeshellarg($this->latestVersion).' '.
escapeshellarg($latestHelperImageVersion).' '.
escapeshellarg($registryUrl),
], $this->server); ], $this->server);
} }
} }

View file

@ -215,6 +215,7 @@ public function applications(Request $request)
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@ -382,6 +383,7 @@ public function create_public_application(Request $request)
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@ -548,6 +550,7 @@ public function create_private_gh_app_application(Request $request)
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'],
@ -741,6 +744,7 @@ public function create_private_deploy_key_application(Request $request)
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@ -875,6 +879,7 @@ public function create_dockerfile_application(Request $request)
'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'],
@ -959,7 +964,7 @@ private function create_application(Request $request, $type)
if ($return instanceof JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'dockerfile_location', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'name' => 'string|max:255', 'name' => 'string|max:255',
@ -997,6 +1002,10 @@ private function create_application(Request $request, $type)
} }
$serverUuid = $request->server_uuid; $serverUuid = $request->server_uuid;
$fqdn = $request->domains; $fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true); $autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy; $instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid; $githubAppUuid = $request->github_app_uuid;
@ -1005,6 +1014,7 @@ private function create_application(Request $request, $type)
$isSpa = $request->is_spa; $isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled;
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network; $connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration; $customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true); $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
@ -1076,7 +1086,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
// ports_exposes is not required for dockercompose // ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') { if ($request->build_pack === 'dockercompose') {
@ -1132,7 +1142,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -1182,15 +1192,15 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson; $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
} }
$repository_url_parsed = Url::fromString($request->git_repository); $repository_url_parsed = Url::fromString($request->git_repository);
$git_host = $repository_url_parsed->getHost(); $git_host = $repository_url_parsed->getHost();
if ($git_host === 'github.com') { if ($git_host === 'github.com') {
$application->source_type = GithubApp::class; $application->source_type = GithubApp::class;
$application->source_id = GithubApp::find(0)->id; $application->source_id = GithubApp::find(0)->id;
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
} }
$application->git_repository = str($repository_url_parsed->getSegment(1).'/'.$repository_url_parsed->getSegment(2))->trim()->toString();
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->destination_id = $destination->id; $application->destination_id = $destination->id;
$application->destination_type = $destination->getMorphClass(); $application->destination_type = $destination->getMorphClass();
@ -1212,6 +1222,10 @@ private function create_application(Request $request, $type)
$application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -1284,7 +1298,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [ $validationMessages = [
@ -1373,7 +1387,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -1423,7 +1437,7 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson; $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->git_repository = str($gitRepository)->trim()->toString(); $application->git_repository = str($gitRepository)->trim()->toString();
@ -1457,6 +1471,10 @@ private function create_application(Request $request, $type)
$application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -1524,7 +1542,7 @@ private function create_application(Request $request, $type)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
@ -1586,7 +1604,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -1636,7 +1654,7 @@ private function create_application(Request $request, $type)
$request->offsetUnset('docker_compose_domains'); $request->offsetUnset('docker_compose_domains');
} }
if ($dockerComposeDomainsJson->count() > 0) { if ($dockerComposeDomainsJson->count() > 0) {
$application->docker_compose_domains = $dockerComposeDomainsJson; $application->docker_compose_domains = json_encode($dockerComposeDomainsJson);
} }
$application->fqdn = $fqdn; $application->fqdn = $fqdn;
$application->private_key_id = $privateKey->id; $application->private_key_id = $privateKey->id;
@ -1666,6 +1684,10 @@ private function create_application(Request $request, $type)
$application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -1790,6 +1812,10 @@ private function create_application(Request $request, $type)
$application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -1909,6 +1935,10 @@ private function create_application(Request $request, $type)
$application->settings->is_force_https_enabled = $isForceHttpsEnabled; $application->settings->is_force_https_enabled = $isForceHttpsEnabled;
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -2058,6 +2088,13 @@ public function application_by_uuid(Request $request)
default: 100, default: 100,
) )
), ),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
], ],
responses: [ responses: [
new OA\Response( new OA\Response(
@ -2121,8 +2158,9 @@ public function logs_by_uuid(Request $request)
], 400); ], 400);
} }
$lines = $request->query->get('lines', 100) ?: 100; $lines = normalizeLogLines($request->query('lines'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines); $showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($application->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([ return response()->json([
'logs' => $logs, 'logs' => $logs,
@ -2269,6 +2307,7 @@ public function delete_by_uuid(Request $request)
'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'],
'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'],
'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'],
'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'],
'install_command' => ['type' => 'string', 'description' => 'The install command.'], 'install_command' => ['type' => 'string', 'description' => 'The install command.'],
'build_command' => ['type' => 'string', 'description' => 'The build command.'], 'build_command' => ['type' => 'string', 'description' => 'The build command.'],
'start_command' => ['type' => 'string', 'description' => 'The start command.'], 'start_command' => ['type' => 'string', 'description' => 'The start command.'],
@ -2328,6 +2367,7 @@ public function delete_by_uuid(Request $request)
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'], 'is_preserve_repository_enabled' => ['type' => 'boolean', 'description' => 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'],
'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include source commit information in the build. Default is false.'],
], ],
) )
), ),
@ -2413,7 +2453,7 @@ public function update_by_uuid(Request $request)
$this->authorize('update', $application); $this->authorize('update', $application);
$server = $application->destination->server; $server = $application->destination->server;
$allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled']; $allowedFields = ['name', 'description', 'is_static', 'is_spa', 'is_auto_deploy_enabled', 'is_force_https_enabled', 'is_preview_deployments_enabled', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'static_image', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'custom_network_aliases', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_type', 'health_check_command', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'watch_paths', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'dockerfile_location', 'dockerfile_target_build', 'docker_compose_location', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'redirect', 'instant_deploy', 'use_build_server', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'is_container_label_escape_enabled', 'is_preserve_repository_enabled', 'include_source_commit_in_build'];
$validationRules = [ $validationRules = [
'name' => 'string|max:255', 'name' => 'string|max:255',
@ -2423,11 +2463,13 @@ public function update_by_uuid(Request $request)
'docker_compose_domains' => 'array|nullable', 'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain', 'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required', 'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable', 'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable', 'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable', 'is_http_basic_auth_enabled' => 'boolean|nullable',
'is_preview_deployments_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string', 'http_basic_auth_username' => 'string',
'http_basic_auth_password' => 'string', 'http_basic_auth_password' => 'string',
'include_source_commit_in_build' => 'boolean',
]; ];
$validationRules = array_merge(sharedDataApplications(), $validationRules); $validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [ $validationMessages = [
@ -2527,29 +2569,7 @@ public function update_by_uuid(Request $request)
$requestHasDomains = $request->has('domains'); $requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) { if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid; $uuid = $request->uuid;
$urls = $request->domains; $errors = ValidationPatterns::validateApplicationDomains($request->domains);
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([
@ -2557,6 +2577,9 @@ public function update_by_uuid(Request $request)
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) { if (isset($result['error'])) {
@ -2601,7 +2624,7 @@ public function update_by_uuid(Request $request)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -2660,10 +2683,12 @@ public function update_by_uuid(Request $request)
$isSpa = $request->is_spa; $isSpa = $request->is_spa;
$isAutoDeployEnabled = $request->is_auto_deploy_enabled; $isAutoDeployEnabled = $request->is_auto_deploy_enabled;
$isForceHttpsEnabled = $request->is_force_https_enabled; $isForceHttpsEnabled = $request->is_force_https_enabled;
$isPreviewDeploymentsEnabled = $request->is_preview_deployments_enabled;
$connectToDockerNetwork = $request->connect_to_docker_network; $connectToDockerNetwork = $request->connect_to_docker_network;
$useBuildServer = $request->use_build_server; $useBuildServer = $request->use_build_server;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled'); $isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled');
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled'); $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled');
$includeSourceCommitInBuild = $request->boolean('include_source_commit_in_build');
if (isset($useBuildServer)) { if (isset($useBuildServer)) {
$application->settings->is_build_server_enabled = $useBuildServer; $application->settings->is_build_server_enabled = $useBuildServer;
$application->settings->save(); $application->settings->save();
@ -2689,6 +2714,11 @@ public function update_by_uuid(Request $request)
$application->settings->save(); $application->settings->save();
} }
if (isset($isPreviewDeploymentsEnabled)) {
$application->settings->is_preview_deployments_enabled = $isPreviewDeploymentsEnabled;
$application->settings->save();
}
if (isset($connectToDockerNetwork)) { if (isset($connectToDockerNetwork)) {
$application->settings->connect_to_docker_network = $connectToDockerNetwork; $application->settings->connect_to_docker_network = $connectToDockerNetwork;
$application->settings->save(); $application->settings->save();
@ -2702,6 +2732,10 @@ public function update_by_uuid(Request $request)
$application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled; $application->settings->is_preserve_repository_enabled = $isPreserveRepositoryEnabled;
$application->settings->save(); $application->settings->save();
} }
if ($request->has('include_source_commit_in_build')) {
$application->settings->include_source_commit_in_build = $includeSourceCommitInBuild;
$application->settings->save();
}
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);
$data = $request->only($allowedFields); $data = $request->only($allowedFields);
@ -3669,7 +3703,7 @@ public function action_deploy(Request $request)
'team_id' => $teamId, 'team_id' => $teamId,
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force, 'force_rebuild' => $force,
'instant_deploy' => $instant_deploy, 'instant_deploy' => $instant_deploy,
]); ]);
@ -3677,7 +3711,7 @@ public function action_deploy(Request $request)
return response()->json( return response()->json(
[ [
'message' => 'Deployment request queued.', 'message' => 'Deployment request queued.',
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
], ],
200 200
); );
@ -3863,13 +3897,13 @@ public function action_restart(Request $request)
'team_id' => $teamId, 'team_id' => $teamId,
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
]); ]);
return response()->json( return response()->json(
[ [
'message' => 'Restart request queued.', 'message' => 'Restart request queued.',
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
], ],
); );
} }
@ -3916,36 +3950,16 @@ private function validateDataApplications(Request $request, Server $server)
} }
if ($request->has('domains') && $server->isProxyShouldRun()) { if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid; $uuid = $request->uuid;
$urls = $request->domains; $errors = ValidationPatterns::validateApplicationDomains($request->domains);
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
if (count($errors) > 0) { if (count($errors) > 0) {
return response()->json([ return response()->json([
'message' => 'Validation failed.', 'message' => 'Validation failed.',
'errors' => $errors, 'errors' => $errors,
], 422); ], 422);
} }
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts // Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) { if (isset($result['error'])) {

View file

@ -2335,6 +2335,116 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(['message' => 'Invalid database type requested.'], 400); return response()->json(['message' => 'Invalid database type requested.'], 400);
} }
#[OA\Get(
summary: 'Get database logs.',
description: 'Get database logs by UUID.',
path: '/databases/{uuid}/logs',
operationId: 'get-database-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Databases'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the database.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get database logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$containers = getCurrentDatabaseContainerStatus($database->destination->server, $database->id);
if ($containers->count() == 0) {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$container = $containers->first();
$status = getContainerStatus($database->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($database->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete( #[OA\Delete(
summary: 'Delete', summary: 'Delete',
description: 'Delete database by UUID.', description: 'Delete database by UUID.',

View file

@ -369,7 +369,7 @@ public function deploy(Request $request)
$uuids = $request->input('uuid'); $uuids = $request->input('uuid');
$tags = $request->input('tag'); $tags = $request->input('tag');
$force = $request->input('force') ?? false; $force = $request->boolean('force');
$pullRequestId = $request->input('pull_request_id', $request->input('pr')); $pullRequestId = $request->input('pull_request_id', $request->input('pr'));
$pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0; $pr = $pullRequestId ? max((int) $pullRequestId, 0) : 0;
$dockerTag = $request->string('docker_tag')->trim()->value() ?: null; $dockerTag = $request->string('docker_tag')->trim()->value() ?: null;
@ -429,7 +429,7 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
} }
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) { if ($deployment_uuid) {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]); $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid]);
} else { } else {
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]); $deployments->push(['message' => $return_message, 'resource_uuid' => $uuid]);
} }
@ -475,7 +475,7 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
} }
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result; ['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
if ($deployment_uuid) { if ($deployment_uuid) {
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]); $deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid]);
} }
$message = $message->merge($return_message); $message = $message->merge($return_message);
} }
@ -533,7 +533,7 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
'resource_type' => 'application', 'resource_type' => 'application',
'application_uuid' => $resource->uuid, 'application_uuid' => $resource->uuid,
'application_name' => $resource->name, 'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(), 'deployment_uuid' => $deployment_uuid,
'force_rebuild' => $force, 'force_rebuild' => $force,
'pull_request_id' => $pr, 'pull_request_id' => $pr,
]); ]);

View file

@ -136,7 +136,7 @@ public function list_github_apps(Request $request)
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'], 'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'], 'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
], ],
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'], required: ['name', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
), ),
), ),
], ],
@ -212,10 +212,14 @@ public function create_github_app(Request $request)
'is_system_wide', 'is_system_wide',
]; ];
$request->merge([
'organization' => normalizeGithubOrganization($request->input('organization')),
]);
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'organization' => 'nullable|string|max:255', 'organization' => ['nullable', 'string', 'max:255', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl], 'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255', 'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535', 'custom_port' => 'nullable|integer|min:1|max:65535',
@ -259,7 +263,9 @@ public function create_github_app(Request $request)
'uuid' => Str::uuid(), 'uuid' => Str::uuid(),
'name' => $request->input('name'), 'name' => $request->input('name'),
'organization' => $request->input('organization'), 'organization' => $request->input('organization'),
'api_url' => $request->input('api_url'), 'api_url' => filled($request->input('api_url'))
? $request->input('api_url')
: githubApiUrlFromHtmlUrl($request->input('html_url')),
'html_url' => $request->input('html_url'), 'html_url' => $request->input('html_url'),
'custom_user' => $request->input('custom_user', 'git'), 'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22), 'custom_port' => $request->input('custom_port', 22),
@ -596,13 +602,17 @@ public function update_github_app(Request $request, $github_app_id)
$payload = $request->only($allowedFields); $payload = $request->only($allowedFields);
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
// Validate the request // Validate the request
$rules = []; $rules = [];
if (isset($payload['name'])) { if (isset($payload['name'])) {
$rules['name'] = 'string'; $rules['name'] = 'string';
} }
if (isset($payload['organization'])) { if (isset($payload['organization'])) {
$rules['organization'] = 'nullable|string'; $rules['organization'] = ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'];
} }
if (isset($payload['api_url'])) { if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl]; $rules['api_url'] = ['url', new SafeExternalUrl];
@ -646,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
], 422); ], 422);
} }
if (array_key_exists('organization', $payload)) {
$payload['organization'] = normalizeGithubOrganization($payload['organization']);
}
if (isset($payload['html_url']) && ! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = githubApiUrlFromHtmlUrl($payload['html_url']);
}
// Handle private_key_uuid -> private_key_id conversion // Handle private_key_uuid -> private_key_id conversion
if (isset($payload['private_key_uuid'])) { if (isset($payload['private_key_uuid'])) {
$privateKey = PrivateKey::where('team_id', $teamId) $privateKey = PrivateKey::where('team_id', $teamId)

View file

@ -112,19 +112,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter(); return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
}); });
$urls = $urls->map(function ($url) use (&$errors) { $errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
if (! filter_var($url, FILTER_VALIDATE_URL)) { $urls = collect(ValidationPatterns::applicationDomainList(
$errors[] = "Invalid URL: {$url}"; ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$duplicates = $urls->duplicates()->unique()->values(); $duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@ -153,10 +144,10 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
} }
if (filled($containerUrls)) { if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim(); $containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower()); $containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid); $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) { if (isset($result['error'])) {
$errors[] = $result['error']; $errors[] = $result['error'];
@ -168,8 +159,6 @@ private function applyServiceUrls(Service $service, array $urlsArray, string $te
return; return;
} }
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else { } else {
$containerUrls = null; $containerUrls = null;
} }
@ -803,6 +792,125 @@ public function service_by_uuid(Request $request)
return response()->json($this->removeSensitiveData($service)); return response()->json($this->removeSensitiveData($service));
} }
#[OA\Get(
summary: 'Get service logs.',
description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.',
path: '/services/{uuid}/logs',
operationId: 'get-service-logs-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Services'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'UUID of the service.',
required: true,
schema: new OA\Schema(
type: 'string',
format: 'uuid',
)
),
new OA\Parameter(
name: 'sub_service_name',
in: 'query',
description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.',
required: true,
schema: new OA\Schema(type: 'string', example: 'appwrite-console'),
),
new OA\Parameter(
name: 'lines',
in: 'query',
description: 'Number of lines to show from the end of the logs.',
required: false,
schema: new OA\Schema(
type: 'integer',
format: 'int32',
default: 100,
)
),
new OA\Parameter(
name: 'show_timestamps',
in: 'query',
description: 'Show timestamps in the logs.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false),
),
],
responses: [
new OA\Response(
response: 200,
description: 'Get service logs by UUID.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function logs_by_uuid(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$subServiceName = $request->query->get('sub_service_name');
if (! $subServiceName) {
return response()->json(['message' => 'Sub service name is required.'], 400);
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$name = "{$subServiceName}-{$service->uuid}";
$containers = getCurrentServiceSubContainerStatus($service->destination->server, $service->id, $name);
$container = $containers->first();
if (! $container) {
return response()->json(['message' => 'Container not found.'], 404);
}
$status = getContainerStatus($service->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Container is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = getContainerLogs($service->destination->server, $container['ID'], $lines, $showTimestamps);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Delete( #[OA\Delete(
summary: 'Delete', summary: 'Delete',
description: 'Delete service by UUID.', description: 'Delete service by UUID.',

View file

@ -98,7 +98,7 @@ public function forgot_password(Request $request)
public function link() public function link()
{ {
$token = request()->get('token'); $token = request()->get('token');
if ($token) { if (is_string($token) && $token !== '') {
try { try {
$decrypted = Crypt::decryptString($token); $decrypted = Crypt::decryptString($token);
} catch (DecryptException) { } catch (DecryptException) {
@ -126,9 +126,8 @@ public function link()
$invitation = TeamInvitation::query() $invitation = TeamInvitation::query()
->where('email', $email) ->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid)) ->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first(); ->first();
if (! $invitation || ! $invitation->isValid()) { if (! $invitation || ! $this->invitationLinkMatchesToken($invitation, $token) || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.'); return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
} }
@ -139,10 +138,11 @@ public function link()
} }
$invitation->delete(); $invitation->delete();
Auth::login($user);
$user->forceFill([ $user->forceFill([
'password' => Hash::make(Str::random(64)), 'password' => Hash::make(Str::random(64)),
])->save(); ])->save();
Auth::login($user);
session(['currentTeam' => $team]); session(['currentTeam' => $team]);
return redirect()->route('dashboard'); return redirect()->route('dashboard');
@ -152,6 +152,19 @@ public function link()
return redirect()->route('login')->with('error', 'Invalid credentials.'); return redirect()->route('login')->with('error', 'Invalid credentials.');
} }
private function invitationLinkMatchesToken(TeamInvitation $invitation, string $token): bool
{
$query = parse_url($invitation->link, PHP_URL_QUERY);
if (! is_string($query)) {
return false;
}
parse_str($query, $parameters);
$storedToken = $parameters['token'] ?? null;
return is_string($storedToken) && hash_equals($storedToken, $token);
}
public function showInvitation() public function showInvitation()
{ {
$invitationUuid = request()->route('uuid'); $invitationUuid = request()->route('uuid');

View file

@ -162,7 +162,7 @@ public function manual(Request $request)
'mode' => 'manual', 'mode' => 'manual',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
'commit' => $commit, 'commit' => $commit,
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
]); ]);

View file

@ -148,7 +148,7 @@ public function manual(Request $request)
'mode' => 'manual', 'mode' => 'manual',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'), 'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
]); ]);

View file

@ -190,7 +190,7 @@ public function manual(Request $request)
'mode' => 'manual', 'mode' => 'manual',
'application_uuid' => $application->uuid, 'application_uuid' => $application->uuid,
'application_name' => $application->name, 'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(), 'deployment_uuid' => $deployment_uuid,
'commit' => data_get($payload, 'after'), 'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null, 'repository' => $full_name ?? null,
]); ]);

View file

@ -52,6 +52,21 @@ 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 DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1; public $tries = 1;
public $timeout = 3600; public $timeout = 3600;
@ -1701,6 +1716,10 @@ private function generate_buildtime_environment_variables()
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove // For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) { if ($env->is_literal || $env->is_multiline) {
@ -1752,6 +1771,10 @@ private function generate_buildtime_environment_variables()
} }
foreach ($sorted_environment_variables as $env) { foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer); $resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove // For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) { if ($env->is_literal || $env->is_multiline) {
@ -2124,7 +2147,7 @@ private function create_workdir()
private function prepare_builder_image(bool $firstTry = true) private function prepare_builder_image(bool $firstTry = true)
{ {
$this->checkForCancellation(); $this->checkForCancellation();
$helperImage = config('constants.coolify.helper_image'); $helperImage = coolifyHelperImage();
$helperImage = "{$helperImage}:".getHelperVersion(); $helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory // Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server); $this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
@ -2229,7 +2252,7 @@ private function set_coolify_variables()
// Only include SOURCE_COMMIT in build context if enabled in settings // Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) { if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} "; $this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
} }
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn; $fqdn = $this->application->fqdn;
@ -2241,17 +2264,33 @@ private function set_coolify_variables()
$fqdn = $url->getHost(); $fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString(); $url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) { if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} "; $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} "; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else { } else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} "; $this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= "COOLIFY_FQDN={$url} "; $this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
} }
} }
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' '; $this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
} }
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; $this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
} }
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@ -2327,7 +2366,7 @@ private function check_git_if_build_needed()
], ],
); );
} }
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) { if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings) // Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines // Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
// Note: Git warnings can be on the same line as the result (no newline) // Note: Git warnings can be on the same line as the result (no newline)
@ -2359,6 +2398,13 @@ private function check_git_if_build_needed()
} }
} }
private function shouldResolveBranchHeadCommit(): bool
{
$commit = trim($this->commit);
return $commit === '' || $commit === 'HEAD';
}
private function clone_repository() private function clone_repository()
{ {
$importCommands = $this->generate_git_import_commands(); $importCommands = $this->generate_git_import_commands();
@ -2566,6 +2612,20 @@ private function generate_nixpacks_env_variables()
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' '); $this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
} }
private function is_reserved_docker_client_env_key(?string $key): bool
{
if (blank($key)) {
return false;
}
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
}
private function without_reserved_docker_client_variables(Collection $variables): Collection
{
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
}
private function generate_railpack_env_variables(): Collection private function generate_railpack_env_variables(): Collection
{ {
$variables = $this->railpack_build_variables(); $variables = $this->railpack_build_variables();
@ -2686,6 +2746,8 @@ private function railpack_build_secret_flags(Collection $variables): string
private function railpack_build_command(string $imageName, Collection $variables): string private function railpack_build_command(string $imageName, Collection $variables): string
{ {
$variables = $this->without_reserved_docker_client_variables($variables);
$cacheArgs = ''; $cacheArgs = '';
if ($this->force_rebuild) { if ($this->force_rebuild) {
$cacheArgs = '--no-cache'; $cacheArgs = '--no-cache';
@ -2712,7 +2774,7 @@ private function railpack_build_command(string $imageName, Collection $variables
$secretFlags = $this->railpack_build_secret_flags($variables); $secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version'); $frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
$buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack" $buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host" ." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\"" ." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}" ." {$cacheArgs}"
@ -2723,7 +2785,7 @@ private function railpack_build_command(string $imageName, Collection $variables
." -t {$imageName}" ." -t {$imageName}"
." {$this->workdir}"; ." {$this->workdir}";
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true' return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand); .' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
} }
@ -2878,9 +2940,25 @@ private function ensure_docker_buildx_available_for_railpack(): void
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.'); throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
} }
private function ensure_helper_docker_buildx_available_for_railpack(): void
{
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
'hidden' => true,
'save' => 'railpack_helper_buildx_available',
]);
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
}
private function build_railpack_image(): void private function build_railpack_image(): void
{ {
$this->ensure_docker_buildx_available_for_railpack(); $this->ensure_docker_buildx_available_for_railpack();
$this->ensure_helper_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables(); $railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file(); $railpackConfigPath = $this->generate_railpack_config_file();
@ -4080,6 +4158,10 @@ private function generate_docker_env_flags_for_secrets()
$variables = $this->env_args; $variables = $this->env_args;
if ($this->build_pack === 'railpack') {
$variables = $this->without_reserved_docker_client_variables($variables);
}
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
return ''; return '';
} }
@ -4220,7 +4302,7 @@ private function add_build_env_variables_to_dockerfile()
$coolify_vars = collect(explode(' ', trim($this->coolify_variables))) $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter() ->filter()
->map(function ($var) { ->map(function ($var) {
return "ARG {$var}"; return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
}); });
$argsToInsert = $argsToInsert->merge($coolify_vars); $argsToInsert = $argsToInsert->merge($coolify_vars);
} }
@ -4242,7 +4324,7 @@ private function add_build_env_variables_to_dockerfile()
$coolify_vars = collect(explode(' ', trim($this->coolify_variables))) $coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter() ->filter()
->map(function ($var) { ->map(function ($var) {
return "ARG {$var}"; return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
}); });
$argsToInsert = $argsToInsert->merge($coolify_vars); $argsToInsert = $argsToInsert->merge($coolify_vars);
} }

View file

@ -36,7 +36,7 @@ public function handle(): void
'active_deployment_uuids' => $activeDeployments, 'active_deployment_uuids' => $activeDeployments,
]); ]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false); $containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers)); $helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) { if ($helperContainers->count() > 0) {

View file

@ -16,6 +16,7 @@
use App\Notifications\Database\BackupFailed; use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess; use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning; use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -716,8 +717,12 @@ private function upload_to_s3(): void
$escapedSecret = escapeshellarg($secret); $escapedSecret = escapeshellarg($secret);
$escapedBackupLocation = escapeshellarg($this->backup_location); $escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/"); $escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}"; $commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true); instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
@ -734,7 +739,7 @@ private function upload_to_s3(): void
private function getFullImageName(): string private function getFullImageName(): string
{ {
$helperImage = config('constants.coolify.helper_image'); $helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion(); $latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}"; return "{$helperImage}:{$latestVersion}";

View file

@ -51,13 +51,24 @@ public function handle(): void
if ($validator->fails()) { if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [ Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl, 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(), 'errors' => $validator->errors()->all(),
]); ]);
return; return;
} }
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload()); try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
} }
} }

View file

@ -44,15 +44,26 @@ public function handle(): void
if ($validator->fails()) { if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [ Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl, 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(), 'errors' => $validator->errors()->all(),
]); ]);
return; return;
} }
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
if ($this->isSlackWebhook()) { if ($this->isSlackWebhook()) {
$this->sendToSlack(); $this->sendToSlack($httpOptions);
return; return;
} }
@ -62,7 +73,7 @@ public function handle(): void
* *
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708 * @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/ */
$this->sendToMattermost(); $this->sendToMattermost($httpOptions);
} }
private function isSlackWebhook(): bool private function isSlackWebhook(): bool
@ -79,9 +90,12 @@ private function isSlackWebhook(): bool
return $scheme === 'https' && $host === 'hooks.slack.com'; return $scheme === 'https' && $host === 'hooks.slack.com';
} }
private function sendToSlack(): void /**
* @param array<string, mixed> $httpOptions
*/
private function sendToSlack(array $httpOptions): void
{ {
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ Http::withOptions($httpOptions)->post($this->webhookUrl, [
'text' => $this->message->title, 'text' => $this->message->title,
'blocks' => [ 'blocks' => [
[ [
@ -119,11 +133,14 @@ private function sendToSlack(): void
/** /**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type. * @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/ */
private function sendToMattermost(): void /**
* @param array<string, mixed> $httpOptions
*/
private function sendToMattermost(array $httpOptions): void
{ {
$username = config('app.name'); $username = config('app.name');
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [ Http::withOptions($httpOptions)->post($this->webhookUrl, [
'username' => $username, 'username' => $username,
'attachments' => [ 'attachments' => [
[ [

View file

@ -50,28 +50,24 @@ public function handle(): void
if ($validator->fails()) { if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [ Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl, 'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(), 'errors' => $validator->errors()->all(),
]); ]);
return; return;
} }
if (isDev()) { try {
ray('Sending webhook notification', [ $httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
'url' => $this->webhookUrl, } catch (\RuntimeException $e) {
'payload' => $this->payload, Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]); ]);
return;
} }
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload); Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
'status' => $response->status(),
'body' => $response->body(),
'successful' => $response->successful(),
]);
}
} }
} }

View file

@ -179,7 +179,6 @@ private function checkHetznerStatus(): void
$this->server->update(['hetzner_server_status' => $status]); $this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status; $this->server->hetzner_server_status = $status;
if ($status === 'off') { if ($status === 'off') {
ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off'); throw new \Exception('Server is powered off');
} }
} }

View file

@ -251,7 +251,6 @@ private function loadSearchableItems()
$cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id); $cacheKey = self::getCacheKey(auth()->user()->currentTeam()->id);
$this->allSearchableItems = Cache::remember($cacheKey, 300, function () { $this->allSearchableItems = Cache::remember($cacheKey, 300, function () {
ray()->showQueries();
$items = collect(); $items = collect();
$team = auth()->user()->currentTeam(); $team = auth()->user()->currentTeam();
@ -530,7 +529,6 @@ private function loadSearchableItems()
'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'), 'search_text' => strtolower($server->name.' '.$server->ip.' '.$server->description.' server servers'),
]; ];
}); });
ray($servers);
// Get all projects // Get all projects
$projects = Project::ownedByCurrentTeam() $projects = Project::ownedByCurrentTeam()
->withCount(['environments', 'applications', 'services']) ->withCount(['environments', 'applications', 'services'])

View file

@ -168,13 +168,6 @@ public function saveModel()
$this->syncData(true); $this->syncData(true);
refreshSession(); refreshSession();
if (isDev()) {
ray('Webhook settings saved', [
'webhook_enabled' => $this->settings->webhook_enabled,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->dispatch('success', 'Settings saved.'); $this->dispatch('success', 'Settings saved.');
} }
@ -183,13 +176,6 @@ public function sendTestNotification()
try { try {
$this->authorize('sendTest', $this->settings); $this->authorize('sendTest', $this->settings);
if (isDev()) {
ray('Sending test webhook notification', [
'team_id' => $this->team->id,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->team->notify(new Test(channel: 'webhook')); $this->team->notify(new Test(channel: 'webhook'));
$this->dispatch('success', 'Test notification sent.'); $this->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -12,7 +12,6 @@
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Livewire\Component; use Livewire\Component;
use Livewire\Features\SupportEvents\Event; use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
class General extends Component class General extends Component
{ {
@ -142,7 +141,8 @@ protected function rules(): array
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable', 'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required', 'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch], 'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@ -771,16 +771,7 @@ public function submit($showToaster = true)
$oldBaseDirectory = $this->application->base_directory; $oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion // Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));
@ -863,6 +854,9 @@ public function submit($showToaster = true)
} }
} }
if ($this->buildPack === 'dockercompose') { if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains); $this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) { if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) { foreach ($this->parsedServiceDomains as $service) {

View file

@ -6,6 +6,7 @@
use App\Jobs\DeleteResourceJob; use App\Jobs\DeleteResourceJob;
use App\Models\Application; use App\Models\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
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 Livewire\Component; use Livewire\Component;
@ -117,12 +118,14 @@ public function save_preview($preview_id)
}); });
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) { if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey]; $fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) { if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim(); $fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$this->previewFqdns[$previewKey] = $fqdn; $this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) { if (! validateDNSEntry($fqdn, $this->application->destination->server)) {

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application; namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview; use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url; use Spatie\Url\Url;
@ -33,6 +34,11 @@ public function save()
{ {
try { try {
$this->authorize('update', $this->preview->application); $this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains'); $docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: []; $docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@ -73,9 +79,13 @@ public function generate()
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn); $preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn; $preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else { } else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application // Use the existing domain from the main application
// Handle multiple domains separated by commas // Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string); $domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = []; $preview_fqdns = [];
$template = $this->preview->application->preview_url_template; $template = $this->preview->application->preview_url_template;
$random = new_public_id(); $random = new_public_id();

View file

@ -14,6 +14,7 @@
use App\Models\StandaloneMysql; use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis; use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator; use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@ -598,6 +599,7 @@ public function checkS3File()
'bucket' => $s3Storage->bucket, 'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint, 'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true, 'use_path_style_endpoint' => true,
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]); ]);
// Check if file exists // Check if file exists
@ -678,7 +680,7 @@ public function restoreFromS3(string $password = ''): bool|string
} }
// Get helper image // Get helper image
$helperImage = config('constants.coolify.helper_image'); $helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion(); $latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}"; $fullImageName = "{$helperImage}:{$latestVersion}";

View file

@ -112,14 +112,17 @@ public function loadServices()
$default_logo = 'images/default.webp'; $default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo); $logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo); $local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [ return [
'name' => str($key)->headline(), 'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'logo' => asset($logo), 'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path) 'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo ? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo), : asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null, 'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + (array) $service; ] + (array) $service;
})->all(); })->all();
@ -336,7 +339,10 @@ private function formatLastModified(string $path): ?string
public function setType(string $type) public function setType(string $type)
{ {
$type = str($type)->lower()->slug()->value(); if (! str($type)->startsWith('one-click-service-')) {
$type = str($type)->lower()->slug()->value();
}
if ($this->loading) { if ($this->loading) {
return; return;
} }

View file

@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service; namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component class EditDomain extends Component
{ {
@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null; public $requiredPort = null;
#[Validate(['nullable'])] #[Validate]
public ?string $fqdn = null; public ?string $fqdn = null;
protected $rules = [ protected function rules(): array
'fqdn' => 'nullable', {
]; return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount() public function mount()
{ {
@ -82,15 +85,9 @@ public function submit()
{ {
try { try {
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -8,11 +8,11 @@
use App\Models\Service; use App\Models\Service;
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use App\Models\ServiceDatabase; use App\Models\ServiceDatabase;
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 Illuminate\Support\Facades\DB;
use Livewire\Component; use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component class Index extends Component
{ {
@ -480,15 +480,11 @@ public function submitApplication()
{ {
try { try {
$this->authorize('update', $this->serviceApplication); $this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString(); $this->validate([
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString(); 'fqdn' => ValidationPatterns::applicationDomainRules(),
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) { ]);
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower(); $this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
});
$this->fqdn = $domains->unique()->implode(',');
$warning = sslipDomainWarning($this->fqdn); $warning = sslipDomainWarning($this->fqdn);
if ($warning) { if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain')); $this->dispatch('warning', __('warning.sslipdomain'));

View file

@ -93,6 +93,10 @@ public function getEnvironmentVariablesPreviewProperty()
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{ {
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
$query = $isPreview $query = $isPreview
? $this->resource->environment_variables_preview() ? $this->resource->environment_variables_preview()
: $this->resource->environment_variables(); : $this->resource->environment_variables();
@ -119,12 +123,21 @@ private function searchTerm(): string
return trim($this->search); return trim($this->search);
} }
private function supportsPreviewEnvironmentVariables(): bool
{
return $this->showPreview && $this->resource instanceof Application;
}
public function getHasEnvironmentVariablesProperty(): bool public function getHasEnvironmentVariablesProperty(): bool
{ {
return $this->environmentVariables->isNotEmpty() || $hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() || $this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() || $this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty(); $hasPreviewEnvironmentVariables;
} }
private function nullLockedValues($envs) private function nullLockedValues($envs)
@ -158,6 +171,10 @@ public function getHardcodedEnvironmentVariablesPreviewProperty()
protected function getHardcodedVariables(bool $isPreview) protected function getHardcodedVariables(bool $isPreview)
{ {
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect([]);
}
// Only for services and docker-compose applications // Only for services and docker-compose applications
if ($this->resource->type() !== 'service' && if ($this->resource->type() !== 'service' &&
($this->resourceClass !== 'App\Models\Application' || ($this->resourceClass !== 'App\Models\Application' ||

View file

@ -90,7 +90,6 @@ private function getContainersForServer($server)
} }
} catch (\Exception $e) { } catch (\Exception $e) {
// Log error but don't fail the entire operation // Log error but don't fail the entire operation
ray("Error loading containers for server {$server->name}: ".$e->getMessage());
return []; return [];
} }

View file

@ -537,6 +537,20 @@ public function loadHetznerTokens(): void
->get(); ->get();
} }
#[Computed]
public function limaStartCommand(): ?string
{
if (! isDev()) {
return null;
}
return match ($this->server->uuid) {
'lima-ubuntu-2404' => 'limactl start --yes --name=coolify-lima-ubuntu-2404 docker/lima/ubuntu-2404.yaml',
'lima-ubuntu-2604' => 'limactl start --yes --name=coolify-lima-ubuntu-2604 docker/lima/ubuntu-2604.yaml',
default => null,
};
}
public function searchHetznerServer(): void public function searchHetznerServer(): void
{ {
$this->hetznerSearchError = null; $this->hetznerSearchError = null;

View file

@ -43,6 +43,11 @@ class Advanced extends Component
#[Validate('boolean')] #[Validate('boolean')]
public bool $is_mcp_server_enabled; public bool $is_mcp_server_enabled;
public ?string $webhook_allowed_internal_hosts = null;
#[Validate('boolean')]
public bool $webhook_allow_localhost;
public function rules() public function rules()
{ {
return [ return [
@ -56,6 +61,8 @@ public function rules()
'disable_two_step_confirmation' => 'boolean', 'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean', 'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
]; ];
} }
@ -75,6 +82,8 @@ public function mount()
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled; $this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true; $this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false; $this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
} }
public function submit() public function submit()
@ -141,13 +150,21 @@ public function submit()
$this->allowed_ips = implode(',', $validEntries); $this->allowed_ips = implode(',', $validEntries);
} }
$this->instantSave(); $webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts();
if ($webhookAllowedInternalHosts === false) {
return;
}
$this->instantSave($webhookAllowedInternalHosts);
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
} }
public function instantSave() /**
* @param array<int, string>|null $webhookAllowedInternalHosts
*/
public function instantSave(?array $webhookAllowedInternalHosts = null)
{ {
try { try {
$this->authorize('update', $this->settings); $this->authorize('update', $this->settings);
@ -161,6 +178,8 @@ public function instantSave()
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation; $this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled; $this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled; $this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
$this->settings->save(); $this->settings->save();
$this->dispatch('success', 'Settings updated!'); $this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) { } catch (\Exception $e) {
@ -168,6 +187,49 @@ public function instantSave()
} }
} }
/**
* @return array<int, string>|false
*/
private function normalizeWebhookAllowedInternalHosts(): array|false
{
$entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: [])
->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.'))
->filter()
->unique()
->values();
$invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry));
if ($invalidEntries->isNotEmpty()) {
$this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', '));
return false;
}
$this->webhook_allowed_internal_hosts = $entries->implode(',');
return $entries->all();
}
private function isValidWebhookAllowlistEntry(string $entry): bool
{
if (filter_var($entry, FILTER_VALIDATE_IP)) {
return true;
}
if (str_contains($entry, '/')) {
[$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null);
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$maxMask = $isIpv6 ? 128 : 32;
return filter_var($ip, FILTER_VALIDATE_IP) !== false
&& is_numeric($mask)
&& (int) $mask >= 0
&& (int) $mask <= $maxMask;
}
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
public function toggleRegistration($password): bool public function toggleRegistration($password): bool
{ {
if (! verifyPasswordConfirmation($password, $this)) { if (! verifyPasswordConfirmation($password, $this)) {

View file

@ -47,8 +47,6 @@ class Index extends Component
public bool $forceSaveDomains = false; public bool $forceSaveDomains = false;
public $buildActivityId = null;
protected array $messages = [ protected array $messages = [
'fqdn.url' => 'Invalid instance URL.', 'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.', 'fqdn.max' => 'URL must not exceed 255 characters.',

View file

@ -6,6 +6,8 @@
use App\Models\InstanceSettings; use App\Models\InstanceSettings;
use App\Models\Server; use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate; use Livewire\Attributes\Validate;
use Livewire\Component; use Livewire\Component;
@ -26,6 +28,9 @@ class Updates extends Component
#[Validate('boolean')] #[Validate('boolean')]
public bool $is_auto_update_enabled; public bool $is_auto_update_enabled;
#[Validate('required|string|in:docker.io,ghcr.io')]
public string $docker_registry_url;
public function mount() public function mount()
{ {
if (! isInstanceAdmin()) { if (! isInstanceAdmin()) {
@ -39,6 +44,7 @@ public function mount()
$this->auto_update_frequency = $this->settings->auto_update_frequency; $this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency; $this->update_check_frequency = $this->settings->update_check_frequency;
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled; $this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
$this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io';
} }
public function instantSave() public function instantSave()
@ -50,16 +56,51 @@ public function instantSave()
'auto_update_frequency' => ['required', 'string'], 'auto_update_frequency' => ['required', 'string'],
]); ]);
} }
$validated = $this->validate([
'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'],
]);
$this->settings->auto_update_frequency = $this->auto_update_frequency; $this->settings->auto_update_frequency = $this->auto_update_frequency;
$this->settings->update_check_frequency = $this->update_check_frequency; $this->settings->update_check_frequency = $this->update_check_frequency;
$this->settings->is_auto_update_enabled = $this->is_auto_update_enabled; $this->settings->is_auto_update_enabled = $this->is_auto_update_enabled;
$this->settings->docker_registry_url = $validated['docker_registry_url'];
$this->syncRegistryUrlToEnv($validated['docker_registry_url']);
$this->settings->save(); $this->settings->save();
$this->dispatch('success', 'Settings updated!'); $this->dispatch('success', 'Settings updated!');
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }
} }
protected function syncRegistryUrlToEnv(string $registryUrl): void
{
if (! $this->server) {
return;
}
try {
instant_remote_process([
$this->registryEnvSyncCommand($registryUrl),
], $this->server);
} catch (\Exception $e) {
Log::warning('Failed to sync REGISTRY_URL to .env', [
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Failed to sync REGISTRY_URL to .env. Settings were not saved.', previous: $e);
}
}
private function registryEnvSyncCommand(string $registryUrl): string
{
$envFile = '/data/coolify/source/.env';
$sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|");
$registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}");
return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi";
}
public function submit() public function submit()
{ {
try { try {
@ -89,6 +130,8 @@ public function submit()
if ($this->server) { if ($this->server) {
$this->server->setupDynamicProxyConfiguration(); $this->server->setupDynamicProxyConfiguration();
} }
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) { } catch (\Exception $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -8,11 +8,8 @@
use App\Rules\SafeExternalUrl; use App\Rules\SafeExternalUrl;
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\Http;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Lcobucci\JWT\Configuration; use Illuminate\Validation\ValidationException;
use Lcobucci\JWT\Signer\Key\InMemory;
use Lcobucci\JWT\Signer\Rsa\Sha256;
use Livewire\Component; use Livewire\Component;
class Change extends Component class Change extends Component
@ -82,11 +79,13 @@ class Change extends Component
public string $activeTab = 'general'; public string $activeTab = 'general';
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
protected function rules(): array protected function rules(): array
{ {
return [ return [
'name' => 'required|string', 'name' => 'required|string',
'organization' => 'nullable|string', 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl], 'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
'customUser' => 'required|string', 'customUser' => 'required|string',
@ -107,6 +106,19 @@ protected function rules(): array
]; ];
} }
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|| $this->apiUrl === githubApiUrlFromHtmlUrl($this->htmlUrl);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->apiUrl = githubApiUrlFromHtmlUrl($this->htmlUrl);
}
}
public function boot() public function boot()
{ {
if ($this->github_app) { if ($this->github_app) {
@ -123,6 +135,11 @@ private function syncData(bool $toModel = false): void
{ {
if ($toModel) { if ($toModel) {
// Sync TO model (before save) // Sync TO model (before save)
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->github_app->name = $this->name; $this->github_app->name = $this->name;
$this->github_app->organization = $this->organization; $this->github_app->organization = $this->organization;
$this->github_app->api_url = $this->apiUrl; $this->github_app->api_url = $this->apiUrl;
@ -208,6 +225,8 @@ public function checkPermissions()
return; return;
} }
syncGithubAppName($this->github_app);
GithubAppPermissionJob::dispatchSync($this->github_app); GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false); $this->syncData(false);
@ -296,31 +315,14 @@ public function mount()
public function getGithubAppNameUpdatePath() public function getGithubAppNameUpdatePath()
{ {
if (str($this->github_app->organization)->isNotEmpty()) { $name = encodeGithubPathSegment($this->github_app->name);
return "{$this->github_app->html_url}/organizations/{$this->github_app->organization}/settings/apps/{$this->github_app->name}"; $organization = normalizeGithubOrganization($this->github_app->organization);
if (filled($organization)) {
return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
} }
return "{$this->github_app->html_url}/settings/apps/{$this->github_app->name}"; return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
}
private function generateGithubJwt($private_key, $app_id): string
{
$configuration = Configuration::forAsymmetricSigner(
new Sha256,
InMemory::plainText($private_key),
InMemory::plainText($private_key)
);
$now = time();
return $configuration->builder()
->issuedBy((string) $app_id)
->permittedFor('https://api.github.com')
->identifiedBy((string) $now)
->issuedAt(new \DateTimeImmutable("@{$now}"))
->expiresAt(new \DateTimeImmutable('@'.($now + 600)))
->getToken($configuration->signer(), $configuration->signingKey())
->toString();
} }
public function updateGithubAppName() public function updateGithubAppName()
@ -328,39 +330,29 @@ public function updateGithubAppName()
try { try {
$this->authorize('update', $this->github_app); $this->authorize('update', $this->github_app);
$privateKey = PrivateKey::ownedByCurrentTeam()->find($this->github_app->private_key_id); $this->github_app->app_id = $this->appId;
$this->github_app->private_key_id = $this->privateKeyId;
$this->github_app->unsetRelation('privateKey');
if (! $privateKey) { if (! $this->appId) {
$this->dispatch('error', 'App ID is required before synchronizing the GitHub App name.');
return;
}
if (! PrivateKey::ownedByCurrentTeam()->find($this->privateKeyId)) {
$this->dispatch('error', 'No private key found for this GitHub App.'); $this->dispatch('error', 'No private key found for this GitHub App.');
return; return;
} }
$jwt = $this->generateGithubJwt($privateKey->private_key, $this->github_app->app_id); $appSlug = syncGithubAppName($this->github_app, true);
$response = Http::withHeaders([ if ($appSlug) {
'Accept' => 'application/vnd.github+json', $this->name = str($appSlug)->kebab();
'X-GitHub-Api-Version' => '2022-11-28', $this->dispatch('success', 'GitHub App name and private key name synchronized successfully.');
'Authorization' => "Bearer {$jwt}",
])->get("{$this->github_app->api_url}/app");
if ($response->successful()) {
$app_data = $response->json();
$app_slug = $app_data['slug'] ?? null;
if ($app_slug) {
$this->github_app->name = $app_slug;
$this->name = str($app_slug)->kebab();
$privateKey->name = "github-app-{$app_slug}";
$privateKey->save();
$this->github_app->save();
$this->dispatch('success', 'GitHub App name and SSH key name synchronized successfully.');
} else {
$this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
}
} else { } else {
$error_message = $response->json()['message'] ?? 'Unknown error'; $this->dispatch('info', 'Could not find App Name (slug) in GitHub response.');
$this->dispatch('error', "Failed to fetch GitHub App information: {$error_message}");
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -373,11 +365,17 @@ public function submit()
$this->authorize('update', $this->github_app); $this->authorize('update', $this->github_app);
$this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->organization = normalizeGithubOrganization($this->organization);
$this->apiUrl = filled($this->apiUrl)
? $this->apiUrl
: githubApiUrlFromHtmlUrl($this->htmlUrl);
$this->validate(); $this->validate();
$this->syncData(true); $this->syncData(true);
$this->github_app->save(); $this->github_app->save();
$this->dispatch('success', 'Github App updated.'); $this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -5,6 +5,7 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Rules\SafeExternalUrl; use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Component; use Livewire\Component;
class Create extends Component class Create extends Component
@ -25,19 +26,39 @@ class Create extends Component
public bool $is_system_wide = false; public bool $is_system_wide = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount() public function mount()
{ {
$this->name = substr(generate_random_name(), 0, 30); $this->name = substr(generate_random_name(), 0, 30);
} }
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
|| $this->api_url === githubApiUrlFromHtmlUrl($this->html_url);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->api_url = githubApiUrlFromHtmlUrl($this->html_url);
}
}
public function createGitHubApp() public function createGitHubApp()
{ {
try { try {
$this->authorize('createAnyResource'); $this->authorize('createAnyResource');
$this->organization = normalizeGithubOrganization($this->organization);
$this->api_url = filled($this->api_url)
? $this->api_url
: githubApiUrlFromHtmlUrl($this->html_url);
$this->validate([ $this->validate([
'name' => 'required|string', 'name' => 'required|string',
'organization' => 'nullable|string', 'organization' => ['nullable', 'string', 'regex:/\A[^\s\/?#]+\z/'],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl], 'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'html_url' => ['required', 'string', 'url', new SafeExternalUrl], 'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'required|string', 'custom_user' => 'required|string',
@ -60,6 +81,8 @@ public function createGitHubApp()
} }
return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]); return redirectRoute($this, 'source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (ValidationException $e) {
throw $e;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

View file

@ -39,6 +39,16 @@ public function viaLink()
$this->generateInviteLink(sendEmail: false); $this->generateInviteLink(sendEmail: false);
} }
private function invitationUrl(string $routeName, array $parameters): string
{
$fqdn = instanceSettings()->fqdn;
if (filled($fqdn)) {
return rtrim($fqdn, '/').route($routeName, $parameters, false);
}
return route($routeName, $parameters);
}
private function generateInviteLink(bool $sendEmail = false) private function generateInviteLink(bool $sendEmail = false)
{ {
try { try {
@ -61,7 +71,7 @@ private function generateInviteLink(bool $sendEmail = false)
return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.'); return handleError(livewire: $this, customErrorMessage: "$this->email is already a member of ".currentTeam()->name.'.');
} }
$uuid = new_public_id(32); $uuid = new_public_id(32);
$link = url('/').config('constants.invitation.link.base_url').$uuid; $link = $this->invitationUrl('team.invitation.show', ['uuid' => $uuid]);
$user = User::whereEmail($this->email)->first(); $user = User::whereEmail($this->email)->first();
if (is_null($user)) { if (is_null($user)) {
@ -73,7 +83,7 @@ private function generateInviteLink(bool $sendEmail = false)
'force_password_reset' => true, 'force_password_reset' => true,
]); ]);
$token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}"); $token = Crypt::encryptString("{$user->email}@@@{$uuid}@@@{$password}");
$link = route('auth.link', ['token' => $token]); $link = $this->invitationUrl('auth.link', ['token' => $token]);
} }
$invitation = TeamInvitation::whereEmail($this->email)->first(); $invitation = TeamInvitation::whereEmail($this->email)->first();
if (! is_null($invitation)) { if (! is_null($invitation)) {

View file

@ -366,7 +366,7 @@ private function get_environment_variables(?string $environment_variable = null)
private function set_environment_variables(?string $environment_variable = null): ?string private function set_environment_variables(?string $environment_variable = null): ?string
{ {
if (is_null($environment_variable) && $environment_variable === '') { if (is_null($environment_variable)) {
return null; return null;
} }
$environment_variable = trim($environment_variable); $environment_variable = trim($environment_variable);

View file

@ -46,6 +46,8 @@ class InstanceSettings extends Model
'dev_helper_version', 'dev_helper_version',
'is_wire_navigate_enabled', 'is_wire_navigate_enabled',
'is_mcp_server_enabled', 'is_mcp_server_enabled',
'webhook_allowed_internal_hosts',
'webhook_allow_localhost',
]; ];
protected $hidden = [ protected $hidden = [
@ -80,10 +82,16 @@ class InstanceSettings extends Model
'sentinel_token' => 'encrypted', 'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean', 'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean', 'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
]; ];
protected static function booted(): void protected static function booted(): void
{ {
static::created(function () {
Once::flush();
});
static::updated(function ($settings) { static::updated(function ($settings) {
// Clear once() cache so subsequent calls get fresh data // Clear once() cache so subsequent calls get fresh data
Once::flush(); Once::flush();

View file

@ -370,7 +370,6 @@ public function isReadOnlyVolume(): bool
return false; return false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only volume');
return false; return false;
} }

View file

@ -187,7 +187,6 @@ public function isReadOnlyVolume(): bool
return false; return false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only persistent volume');
return false; return false;
} }

View file

@ -178,11 +178,10 @@ public function testConnection(bool $shouldSave = false)
'bucket' => $this['bucket'], 'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'], 'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true, 'use_path_style_endpoint' => true,
'http' => [ 'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS, 'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS, 'timeout' => self::REQUEST_TIMEOUT_SECONDS,
'allow_redirects' => false, ]),
],
]); ]);
// Test the connection by listing files with ListObjectsV2 (S3) // Test the connection by listing files with ListObjectsV2 (S3)
$disk->files(); $disk->files();

View file

@ -1533,7 +1533,6 @@ private function disableSshMux(): void
public function generateCaCertificate() public function generateCaCertificate()
{ {
try { try {
ray('Generating CA certificate for server', $this->id);
SslHelper::generateSslCertificate( SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate', commonName: 'Coolify CA Certificate',
serverId: $this->id, serverId: $this->id,
@ -1541,7 +1540,6 @@ public function generateCaCertificate()
validityDays: 10 * 365 validityDays: 10 * 365
); );
$caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first(); $caCertificate = $this->sslCertificates()->where('is_ca_certificate', true)->first();
ray('CA certificate generated', $caCertificate);
if ($caCertificate) { if ($caCertificate) {
$certificateContent = $caCertificate->ssl_certificate; $certificateContent = $caCertificate->ssl_certificate;
$caCertPath = config('constants.coolify.base_config_path').'/ssl/'; $caCertPath = config('constants.coolify.base_config_path').'/ssl/';

View file

@ -1594,7 +1594,6 @@ public function saveComposeConfigs()
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
} }
} catch (\Exception $e) { } catch (\Exception $e) {
ray($e->getMessage());
} }
} }

View file

@ -15,23 +15,11 @@ public function send($notifiable, Notification $notification): void
$webhookSettings = $notifiable->webhookNotificationSettings; $webhookSettings = $notifiable->webhookNotificationSettings;
if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) { if (! $webhookSettings || ! $webhookSettings->isEnabled() || ! $webhookSettings->webhook_url) {
if (isDev()) {
ray('Webhook notification skipped - not enabled or no URL configured');
}
return; return;
} }
$payload = $notification->toWebhook(); $payload = $notification->toWebhook();
if (isDev()) {
ray('Dispatching webhook notification', [
'notification' => get_class($notification),
'url' => $webhookSettings->webhook_url,
'payload' => $payload,
]);
}
SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url); SendWebhookJob::dispatch($payload, $webhookSettings->webhook_url);
} }
} }

View file

@ -17,8 +17,6 @@ public function __construct(public int $hetznerServerId, public int $teamId, pub
public function via(object $notifiable): array public function via(object $notifiable): array
{ {
ray('hello');
ray($notifiable);
return $notifiable->getEnabledChannels('hetzner_deletion_failed'); return $notifiable->getEnabledChannels('hetzner_deletion_failed');
} }

View file

@ -2,9 +2,13 @@
namespace App\Rules; namespace App\Rules;
use App\Models\InstanceSettings;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Throwable;
class SafeWebhookUrl implements ValidationRule class SafeWebhookUrl implements ValidationRule
{ {
@ -18,8 +22,8 @@ public function __construct(private ?Closure $resolver = null) {}
* *
* Validates that a webhook URL is safe for server-side requests. * Validates that a webhook URL is safe for server-side requests.
* Blocks loopback addresses, cloud metadata endpoints (link-local), * Blocks loopback addresses, cloud metadata endpoints (link-local),
* and dangerous hostnames while allowing private network IPs * private/reserved ranges, and dangerous hostnames unless the
* for self-hosted deployments. * instance operator explicitly allowlists the intranet target.
*/ */
public function validate(string $attribute, mixed $value, Closure $fail): void public function validate(string $attribute, mixed $value, Closure $fail): void
{ {
@ -43,12 +47,17 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
return; return;
} }
if (str_ends_with($host, '.')) {
$fail('The :attribute host must not end with a trailing dot.');
return;
}
$host = strtolower($host); $host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host); $hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.'); $hostForDns = rtrim($hostForIpCheck, '.');
$blockedHosts = ['localhost', '0.0.0.0', '::1']; if ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $host); $this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.'); $fail('The :attribute must not point to localhost or internal hosts.');
@ -56,9 +65,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
} }
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) { if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if ($this->isBlockedIp($hostForIpCheck)) { if (! $this->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck); $this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to loopback or link-local addresses.'); $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return; return;
} }
@ -67,16 +76,124 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
} }
$resolvedIps = $this->resolveHost($hostForDns); $resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) { foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) { if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp); $this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.'); $fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return; return;
} }
} }
} }
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
* @return array<string, mixed>
*/
public static function httpClientOptions(string $url): array
{
$options = ['allow_redirects' => false];
if (! defined('CURLOPT_RESOLVE')) {
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
}
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return $options;
}
$options['curl'] = [
CURLOPT_RESOLVE => array_map(
fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
),
];
return $options;
}
/**
* Build mc --resolve mappings that pin the endpoint host for S3 backups.
*
* @return array<int, string>
*/
public static function minioClientResolveOptions(string $url): array
{
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return [];
}
return array_map(
fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
);
}
public static function redactedUrlForLog(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || ! is_string($host)) {
return '[invalid-url]';
}
return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : '');
}
/**
* @return array{host: string, port: int, ips: array<int, string>}
*/
private static function resolveUrlForRequest(string $url): array
{
$rule = new self;
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
if (str_ends_with($host, '.')) {
throw new \RuntimeException('Webhook URL host must not end with a trailing dot.');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
$port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
if (! $rule->isAllowedIp($hostForDns, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => []];
}
$resolvedIps = $rule->resolveHost($hostForDns);
if ($resolvedIps === []) {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
foreach ($resolvedIps as $resolvedIp) {
if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
}
private function normalizeHostForIpCheck(string $host): string private function normalizeHostForIpCheck(string $host): string
{ {
return (str_starts_with($host, '[') && str_ends_with($host, ']')) return (str_starts_with($host, '[') && str_ends_with($host, ']'))
@ -93,6 +210,15 @@ private function resolveHost(string $host): array
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false)); return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
} }
if ($host === 'localhost') {
return ['127.0.0.1', '::1'];
}
$customDnsServers = $this->customDnsServers();
if ($customDnsServers !== []) {
return $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA); $records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) { if ($records === false) {
$records = []; $records = [];
@ -119,60 +245,320 @@ private function resolveHost(string $host): array
return array_values(array_unique($ips)); return array_values(array_unique($ips));
} }
private function isBlockedIp(string $ip): bool /**
* @param array<int, string> $dnsServers
* @return array<int, string>
*/
private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
{
$ips = [];
foreach ($dnsServers as $dnsServer) {
foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
try {
$query = new DNSQuery($dnsServer, 53, 5);
$records = $query->query($host, $type);
if ($records === false || $query->hasError()) {
continue;
}
foreach ($records as $record) {
if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) {
$ips[] = $record->getData();
}
}
} catch (Throwable) {
continue;
}
}
}
return array_values(array_unique($ips));
}
/**
* @return array<int, string>
*/
private function customDnsServers(): array
{
$servers = $this->instanceSettings()?->custom_dns_servers ?? '';
if (! is_string($servers)) {
return [];
}
return array_values(array_filter(array_map(
fn (string $server): string => trim($server),
explode(',', $servers),
), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
}
private function isAllowedIp(string $ip, string $host): bool
{ {
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip); $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) { if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4); $ip = $embeddedIpv4;
}
if ($this->isPublicIp($ip)) {
return true;
}
if ($this->isLocalhostIp($ip)) {
return $this->allowLocalhost()
&& ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip));
}
if ($this->isPrivateIp($ip)) {
return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
}
return $this->isAllowlistedIp($ip);
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIpv4($embeddedIpv4);
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIp($ip);
}
private function isLocalhostIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isLocalhostIp($embeddedIpv4);
} }
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip); return $this->ipv4InCidr($ip, '127.0.0.0/8');
} }
return $this->isBlockedIpv6($ip); return @inet_pton($ip) === @inet_pton('::1');
} }
private function isBlockedIpv4(string $ip): bool private function isPrivateIp(string $ip): bool
{ {
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) { $embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
&& filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
private function isSpecialUseIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isSpecialUseIpv4($ip);
}
return $this->isSpecialUseIpv6($ip);
}
private function isSpecialUseIpv4(string $ip): bool
{
foreach ([
'0.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'192.0.0.0/24',
'192.0.2.0/24',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255/32',
] as $cidr) {
if ($this->ipv4InCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function isSpecialUseIpv6(string $ip): bool
{
$ipBytes = @inet_pton($ip);
if ($ipBytes === false) {
return false;
}
foreach ([
'::/128',
'::1/128',
'::ffff:0:0/96',
'64:ff9b::/96',
'100::/64',
'2001::/23',
'2001:2::/48',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'ff00::/8',
] as $cidr) {
[$network, $prefix] = explode('/', $cidr, 2);
$networkBytes = @inet_pton($network);
if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) {
return true;
}
}
return false;
}
private function isBlockedHostname(string $host): bool
{
return in_array($host, ['localhost'], true)
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')
|| str_ends_with($host, '.cluster.local');
}
private function isAllowedHostname(string $host): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
return true;
}
}
return false;
}
private function isAllowlistedIp(string $ip): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (str_contains($entry, '/')) {
if ($this->ipInCidr($ip, $entry)) {
return true;
}
continue;
}
if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) {
return true;
}
}
return false;
}
/**
* @return array<int, string>
*/
private function allowlistEntries(): array
{
$entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
if (is_string($entries)) {
$entries = explode(',', $entries);
}
if (! is_array($entries)) {
return [];
}
return array_values(array_filter(array_map(
fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
$entries,
)));
}
private function allowLocalhost(): bool
{
return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
}
private function instanceSettings(): ?InstanceSettings
{
try {
return InstanceSettings::query()->find(0);
} catch (Throwable) {
return null;
}
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, $cidr);
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 128) {
return false;
}
$ipBytes = @inet_pton($ip);
$networkBytes = @inet_pton($network);
if ($ipBytes === false || $networkBytes === false) {
return false;
}
return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
}
private function ipv4InCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 32) {
return false;
}
$ipLong = ip2long($ip);
$networkLong = ip2long($network);
if ($ipLong === false || $networkLong === false) {
return false;
}
$mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
return ($ipLong & $mask) === ($networkLong & $mask);
}
private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
{
$bytes = intdiv($prefix, 8);
$bits = $prefix % 8;
if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
return false;
}
if ($bits === 0) {
return true; return true;
} }
$long = ip2long($ip); $mask = 0xFF << (8 - $bits) & 0xFF;
if ($long === false) {
return false;
}
$unsigned = sprintf('%u', $long); return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
} }
private function extractIpv4FromMappedIpv6(string $ip): ?string private function extractIpv4FromMappedIpv6(string $ip): ?string

View file

@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Exceptions\RateLimitException; use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
class HetznerService class HetznerService
@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = [])
->timeout(30) ->timeout(30)
->retry(3, function (int $attempt, \Exception $exception) { ->retry(3, function (int $attempt, \Exception $exception) {
// Handle rate limiting (429 Too Many Requests) // Handle rate limiting (429 Too Many Requests)
if ($exception instanceof \Illuminate\Http\Client\RequestException) { if ($exception instanceof RequestException) {
$response = $exception->response; $response = $exception->response;
if ($response && $response->status() === 429) { if ($response && $response->status() === 429) {
@ -129,17 +130,9 @@ public function uploadSshKey(string $name, string $publicKey): array
public function createServer(array $params): array public function createServer(array $params): array
{ {
ray('Hetzner createServer request', [
'endpoint' => '/servers',
'params' => $params,
]);
$response = $this->request('post', '/servers', $params); $response = $this->request('post', '/servers', $params);
ray('Hetzner createServer response', [
'response' => $response,
]);
return $response['server'] ?? []; return $response['server'] ?? [];
} }

View file

@ -108,6 +108,12 @@ class ValidationPatterns
*/ */
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u'; public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u';
/**
* Characters that are valid in some URL positions but unsafe for values
* that are later reused in shell assignment contexts.
*/
public const APPLICATION_DOMAIN_FORBIDDEN_PATTERN = '/[`$;&|<>()\\\\\r\n]/';
/** /**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names). * Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore. * Allows letters, digits, underscore; first char must be letter or underscore.
@ -511,6 +517,156 @@ public static function shellSafeCommandRules(int $maxLength = 1000): array
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN]; return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
} }
/**
* Get validation rules for comma-separated application URL fields.
*/
public static function applicationDomainRules(int $maxLength = 2048): array
{
return [
'nullable',
'string',
'max:'.$maxLength,
function (string $attribute, mixed $value, \Closure $fail): void {
foreach (self::validateApplicationDomains($value) as $error) {
$fail($error);
}
},
];
}
/**
* Validate a comma-separated list of application URLs.
*
* @return array<int, string>
*/
public static function validateApplicationDomains(mixed $value): array
{
if (blank($value)) {
return [];
}
if (! is_string($value)) {
return ['The domains field must be a string.'];
}
$errors = [];
foreach (self::applicationDomainList($value) as $url) {
if (preg_match(self::APPLICATION_DOMAIN_FORBIDDEN_PATTERN, $url) === 1) {
$errors[] = "Invalid URL: {$url}";
continue;
}
if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}";
continue;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
continue;
}
if (blank(parse_url($url, PHP_URL_HOST))) {
$errors[] = "Invalid URL: {$url}";
}
}
return $errors;
}
/**
* Normalize a comma-separated application URL list for storage.
*/
public static function normalizeApplicationDomains(?string $value): ?string
{
$urls = self::applicationDomainList($value);
if ($urls === []) {
return null;
}
return collect($urls)
->map(fn (string $url) => self::normalizeApplicationDomainUrl($url))
->implode(',');
}
/**
* Normalize URL components that are case-insensitive while preserving
* case-sensitive path, query, and fragment components.
*/
private static function normalizeApplicationDomainUrl(string $url): string
{
$components = parse_url($url);
if ($components === false) {
return $url;
}
$normalized = '';
if (isset($components['scheme'])) {
$normalized .= strtolower($components['scheme']).'://';
}
if (isset($components['user'])) {
$normalized .= $components['user'];
if (isset($components['pass'])) {
$normalized .= ':'.$components['pass'];
}
$normalized .= '@';
}
if (isset($components['host'])) {
$normalized .= strtolower($components['host']);
}
if (isset($components['port'])) {
$normalized .= ':'.$components['port'];
}
if (isset($components['path'])) {
$normalized .= $components['path'];
}
if (array_key_exists('query', $components)) {
$normalized .= '?'.$components['query'];
}
if (array_key_exists('fragment', $components)) {
$normalized .= '#'.$components['fragment'];
}
return $normalized;
}
/**
* Split a comma-separated application URL list into trimmed URL strings.
*
* @return array<int, string>
*/
public static function applicationDomainList(?string $value): array
{
if (blank($value)) {
return [];
}
return str($value)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn (string $url) => trim($url))
->filter(fn (string $url) => filled($url))
->values()
->all();
}
/** /**
* Get validation rules for Docker volume name fields * Get validation rules for Docker volume name fields
*/ */

View file

@ -3,6 +3,11 @@
namespace App\Traits; namespace App\Traits;
use App\Livewire\GlobalSearch; use App\Livewire\GlobalSearch;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
trait ClearsGlobalSearchCache trait ClearsGlobalSearchCache
@ -20,7 +25,6 @@ protected static function bootClearsGlobalSearchCache()
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Silently fail cache clearing - don't break the save operation // Silently fail cache clearing - don't break the save operation
ray('Failed to clear global search cache on saving: '.$e->getMessage());
} }
}); });
@ -33,7 +37,6 @@ protected static function bootClearsGlobalSearchCache()
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Silently fail cache clearing - don't break the create operation // Silently fail cache clearing - don't break the create operation
ray('Failed to clear global search cache on creation: '.$e->getMessage());
} }
}); });
@ -46,7 +49,6 @@ protected static function bootClearsGlobalSearchCache()
} }
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Silently fail cache clearing - don't break the delete operation // Silently fail cache clearing - don't break the delete operation
ray('Failed to clear global search cache on deletion: '.$e->getMessage());
} }
}); });
} }
@ -58,14 +60,14 @@ private function hasSearchableChanges(): bool
$searchableFields = ['name', 'description']; $searchableFields = ['name', 'description'];
// Add model-specific searchable fields // Add model-specific searchable fields
if ($this instanceof \App\Models\Application) { if ($this instanceof Application) {
$searchableFields[] = 'fqdn'; $searchableFields[] = 'fqdn';
$searchableFields[] = 'docker_compose_domains'; $searchableFields[] = 'docker_compose_domains';
} elseif ($this instanceof \App\Models\Server) { } elseif ($this instanceof Server) {
$searchableFields[] = 'ip'; $searchableFields[] = 'ip';
} elseif ($this instanceof \App\Models\Service) { } elseif ($this instanceof Service) {
// Services don't have direct fqdn, but name and description are covered // Services don't have direct fqdn, but name and description are covered
} elseif ($this instanceof \App\Models\Project || $this instanceof \App\Models\Environment) { } elseif ($this instanceof Project || $this instanceof Environment) {
// Projects and environments only have name and description as searchable // Projects and environments only have name and description as searchable
} }
// Database models only have name and description as searchable // Database models only have name and description as searchable
@ -81,7 +83,6 @@ private function hasSearchableChanges(): bool
return false; return false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
// If checking changes fails, assume changes exist to be safe // If checking changes fails, assume changes exist to be safe
ray('Failed to check searchable changes: '.$e->getMessage());
return true; return true;
} }
@ -91,18 +92,18 @@ private function getTeamIdForCache()
{ {
try { try {
// For Project models (has direct team_id) // For Project models (has direct team_id)
if ($this instanceof \App\Models\Project) { if ($this instanceof Project) {
return $this->team_id ?? null; return $this->team_id ?? null;
} }
// For Environment models (get team_id through project) // For Environment models (get team_id through project)
if ($this instanceof \App\Models\Environment) { if ($this instanceof Environment) {
return $this->project?->team_id; return $this->project?->team_id;
} }
// For database models, team is accessed through environment.project.team // For database models, team is accessed through environment.project.team
if (method_exists($this, 'team')) { if (method_exists($this, 'team')) {
if ($this instanceof \App\Models\Server) { if ($this instanceof Server) {
$team = $this->team; $team = $this->team;
} else { } else {
$team = $this->team(); $team = $this->team();
@ -120,7 +121,6 @@ private function getTeamIdForCache()
return null; return null;
} catch (\Throwable $e) { } catch (\Throwable $e) {
// If we can't determine team ID, return null // If we can't determine team ID, return null
ray('Failed to get team ID for cache: '.$e->getMessage());
return null; return null;
} }

View file

@ -82,7 +82,6 @@ protected function executeWithSshRetry(callable $callback, array $context = [],
$lastErrorMessage = ''; $lastErrorMessage = '';
// Randomly fail the command with a key exchange error for testing // Randomly fail the command with a key exchange error for testing
// if (random_int(1, 10) === 1) { // 10% chance to fail // if (random_int(1, 10) === 1) { // 10% chance to fail
// ray('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer'); // throw new \RuntimeException('SSH key exchange failed: kex_exchange_identification: read: Connection reset by peer');
// } // }
for ($attempt = 0; $attempt < $maxRetries; $attempt++) { for ($attempt = 0; $attempt < $maxRetries; $attempt++) {

View file

@ -112,8 +112,9 @@ function sharedDataApplications()
'is_spa' => 'boolean', 'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean', 'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean', 'is_force_https_enabled' => 'boolean',
'is_preview_deployments_enabled' => 'boolean',
'static_image' => Rule::enum(StaticImageTypes::class), 'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => 'string|nullable', 'domains' => ValidationPatterns::applicationDomainRules(),
'redirect' => Rule::enum(RedirectTypes::class), 'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'], 'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(), 'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
@ -213,10 +214,12 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('is_spa'); $request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled'); $request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled'); $request->offsetUnset('is_force_https_enabled');
$request->offsetUnset('is_preview_deployments_enabled');
$request->offsetUnset('connect_to_docker_network'); $request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override'); $request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain'); $request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled'); $request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled'); $request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('docker_compose_raw'); $request->offsetUnset('docker_compose_raw');
} }

View file

@ -72,6 +72,36 @@ function getCurrentServiceContainerStatus(Server $server, int $id): Collection
return $containers; return $containers;
} }
function getCurrentDatabaseContainerStatus(Server $server, int $id): Collection
{
$containers = collect([]);
if (! $server->isSwarm()) {
$containers = instant_remote_process(["docker ps -a --filter='label=coolify.databaseId={$id}' --format '{{json .}}' "], $server);
$containers = format_docker_command_output_to_json($containers);
return $containers->filter();
}
return $containers;
}
function getCurrentServiceSubContainerStatus(Server $server, int $id, string $name): Collection
{
return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $id), $name);
}
function filterServiceSubContainersByName(Collection $containers, string $name): Collection
{
return $containers->filter(function ($container) use ($name) {
$labels = data_get($container, 'Labels', []);
if (is_string($labels)) {
$labels = format_docker_labels_to_json($labels);
}
return collect($labels)->get('coolify.name') === $name;
})->values();
}
function format_docker_command_output_to_json($rawOutput): Collection function format_docker_command_output_to_json($rawOutput): Collection
{ {
$outputLines = explode(PHP_EOL, $rawOutput); $outputLines = explode(PHP_EOL, $rawOutput);
@ -1247,18 +1277,38 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
} }
} }
function getContainerLogs(Server $server, string $container_id, int $lines = 100): string function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int
{ {
if ($server->isSwarm()) { $lines = filter_var($lines, FILTER_VALIDATE_INT);
$output = instant_remote_process([ if ($lines === false || $lines <= 0) {
"docker service logs -n {$lines} {$container_id} 2>&1", return $default;
], $server);
} else {
$output = instant_remote_process([
"docker logs -n {$lines} {$container_id} 2>&1",
], $server);
} }
return min($lines, $max);
}
function parseLogTimestampFlag(mixed $showTimestamps): bool
{
return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
}
function buildContainerLogsCommand(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
{
$command = "docker logs -n {$lines}";
if ($server->isSwarm()) {
$command = "docker service logs -n {$lines}";
}
if ($showTimestamps) {
$command .= ' --timestamps';
}
return "{$command} ".escapeshellarg($container_id).' 2>&1';
}
function getContainerLogs(Server $server, string $container_id, int $lines = 100, bool $showTimestamps = false): string
{
$output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server);
$output = removeAnsiColors($output); $output = removeAnsiColors($output);
return $output; return $output;

View file

@ -4,6 +4,54 @@
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
function isValidDomainUrl(string $url): bool
{
$components = parse_url($url);
if ($components === false) {
return false;
}
$scheme = $components['scheme'] ?? '';
$host = $components['host'] ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') {
return false;
}
$urlToValidate = $scheme.'://';
if (isset($components['user'])) {
$urlToValidate .= $components['user'];
if (isset($components['pass'])) {
$urlToValidate .= ':'.$components['pass'];
}
$urlToValidate .= '@';
}
$urlToValidate .= str_replace('_', '-', $host);
if (isset($components['port'])) {
$urlToValidate .= ':'.$components['port'];
}
if (isset($components['path'])) {
$urlToValidate .= $components['path'];
}
if (isset($components['query'])) {
$urlToValidate .= '?'.$components['query'];
}
if (isset($components['fragment'])) {
$urlToValidate .= '#'.$components['fragment'];
}
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{ {
$conflicts = []; $conflicts = [];

View file

@ -2,6 +2,7 @@
use App\Models\GithubApp; use App\Models\GithubApp;
use App\Models\GitlabApp; use App\Models\GitlabApp;
use App\Models\PrivateKey;
use Carbon\Carbon; use Carbon\Carbon;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -13,9 +14,144 @@
use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Signer\Rsa\Sha256;
use Lcobucci\JWT\Token\Builder; use Lcobucci\JWT\Token\Builder;
function generateGithubToken(GithubApp $source, string $type) /**
* Extract and normalize the hostname from a GitHub URL.
*
* @param string|null $url The URL to parse
* @return string|null The lowercase hostname, or null if the URL is blank or has no parseable host
*/
function githubUrlHost(?string $url): ?string
{ {
$response = Http::get("{$source->api_url}/zen"); if (blank($url)) {
return null;
}
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || blank($host)) {
return null;
}
return strtolower($host);
}
/**
* Build the scheme://host[:port] origin for a GitHub URL.
*
* This helper fails explicitly for blank, scheme-less, or malformed input when
* githubUrlHost() cannot parse a host, because returning the original input
* would not be a valid origin. Callers should pass already-validated URLs.
*
* @param string $url The URL to derive the origin from
* @return string The normalized origin
*
* @throws InvalidArgumentException When the URL does not contain a parseable scheme and host
*/
function githubUrlOrigin(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = githubUrlHost($url);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || blank($scheme) || ! $host) {
throw new InvalidArgumentException('GitHub URL must include a valid scheme and host.');
}
return $scheme.'://'.$host.($port ? ":{$port}" : '');
}
/**
* Determine whether the URL points at github.com.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubDotComHost(?string $htmlUrl): bool
{
return githubUrlHost($htmlUrl) === 'github.com';
}
/**
* Determine whether the URL points at a *.ghe.com GitHub Enterprise Cloud host.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGheDotComHost(?string $htmlUrl): bool
{
$host = githubUrlHost($htmlUrl);
return is_string($host)
&& Str::endsWith($host, '.ghe.com')
&& ! Str::startsWith($host, 'api.');
}
/**
* Determine whether the URL belongs to GitHub's cloud family (github.com or *.ghe.com).
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubCloudFamilyHost(?string $htmlUrl): bool
{
return isGithubDotComHost($htmlUrl) || isGheDotComHost($htmlUrl);
}
/**
* Determine whether the URL belongs to a self-hosted GitHub Enterprise Server.
*
* @param string|null $htmlUrl The GitHub HTML URL to check
*/
function isGithubEnterpriseServerHost(?string $htmlUrl): bool
{
return filled($htmlUrl) && ! isGithubCloudFamilyHost($htmlUrl);
}
/**
* Derive the GitHub REST API base URL from a GitHub HTML URL.
*
* @param string $htmlUrl The GitHub HTML URL
* @return string The API base URL (api.github.com, api.<host> for *.ghe.com, or <origin>/api/v3 for GHES)
*/
function githubApiUrlFromHtmlUrl(string $htmlUrl): string
{
if (isGithubDotComHost($htmlUrl)) {
return 'https://api.github.com';
}
if (isGheDotComHost($htmlUrl)) {
return 'https://api.'.githubUrlHost($htmlUrl);
}
return githubUrlOrigin($htmlUrl).'/api/v3';
}
/**
* Normalize a GitHub organization slug by trimming surrounding slashes and whitespace.
*
* @param string|null $organization The raw organization value
* @return string|null The trimmed organization, or null when blank
*/
function normalizeGithubOrganization(?string $organization): ?string
{
if (blank($organization)) {
return null;
}
return trim((string) $organization, "/ \t\n\r\0\x0B");
}
/**
* URL-encode a single GitHub path segment.
*
* @param string $segment The raw path segment
* @return string The raw-URL-encoded segment
*/
function encodeGithubPathSegment(string $segment): string
{
return rawurlencode($segment);
}
function assertGithubClockInSync(string $apiUrl): void
{
$response = Http::get("{$apiUrl}/zen");
$serverTime = CarbonImmutable::now()->setTimezone('UTC'); $serverTime = CarbonImmutable::now()->setTimezone('UTC');
$githubTime = Carbon::parse($response->header('date')); $githubTime = Carbon::parse($response->header('date'));
$timeDiff = abs($serverTime->diffInSeconds($githubTime)); $timeDiff = abs($serverTime->diffInSeconds($githubTime));
@ -29,6 +165,11 @@ function generateGithubToken(GithubApp $source, string $type)
'Please synchronize your system clock.' 'Please synchronize your system clock.'
); );
} }
}
function generateGithubToken(GithubApp $source, string $type)
{
assertGithubClockInSync($source->api_url);
$signingKey = InMemory::plainText($source->privateKey->private_key); $signingKey = InMemory::plainText($source->privateKey->private_key);
$algorithm = new Sha256; $algorithm = new Sha256;
@ -117,11 +258,86 @@ function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $m
]; ];
} }
function generateGithubAppJwt(string $privateKey, string|int $appId): string
{
$algorithm = new Sha256;
$tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default()));
$now = CarbonImmutable::now()->setTimezone('UTC');
$now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s'));
return $tokenBuilder
->issuedBy((string) $appId)
->issuedAt($now->modify('-1 minute'))
->expiresAt($now->modify('+8 minutes'))
->getToken($algorithm, InMemory::plainText($privateKey))
->toString();
}
function syncGithubAppName(GithubApp $source, bool $throw = false): ?string
{
try {
if (blank($source->app_id) || blank($source->private_key_id)) {
return null;
}
$privateKey = $source->privateKey ?: PrivateKey::find($source->private_key_id);
if (! $privateKey) {
return null;
}
assertGithubClockInSync($source->api_url);
$jwt = generateGithubAppJwt($privateKey->private_key, $source->app_id);
$response = Http::withHeaders([
'Accept' => 'application/vnd.github+json',
'X-GitHub-Api-Version' => '2022-11-28',
'Authorization' => "Bearer {$jwt}",
])->get("{$source->api_url}/app");
if (! $response->successful()) {
throw new RuntimeException(data_get($response->json(), 'message', 'Failed to fetch GitHub App information.'));
}
$appSlug = data_get($response->json(), 'slug');
if (blank($appSlug)) {
return null;
}
$source->name = $appSlug;
if ($source->exists) {
$source->save();
}
$privateKey->name = "github-app-{$appSlug}";
$privateKey->save();
return $appSlug;
} catch (Throwable $e) {
if ($throw) {
throw $e;
}
return null;
}
}
function getInstallationPath(GithubApp $source): string function getInstallationPath(GithubApp $source): string
{ {
$name = str(Str::kebab($source->name)); $name = encodeGithubPathSegment(Str::kebab($source->name));
$installation_path = $source->html_url === 'https://github.com' ? 'apps' : 'github-apps';
$state = Str::random(64); $state = Str::random(64);
$organization = normalizeGithubOrganization($source->organization);
if (isGithubEnterpriseServerHost($source->html_url)) {
$path = "github-apps/{$name}";
} elseif (isGheDotComHost($source->html_url) && filled($organization)) {
$path = 'apps/'.encodeGithubPathSegment($organization)."/{$name}";
} else {
$path = "apps/{$name}";
}
Cache::put('github-app-setup-state:'.hash('sha256', $state), [ Cache::put('github-app-setup-state:'.hash('sha256', $state), [
'action' => 'install', 'action' => 'install',
@ -129,15 +345,19 @@ function getInstallationPath(GithubApp $source): string
'team_id' => $source->team_id, 'team_id' => $source->team_id,
], now()->addMinutes(60)); ], now()->addMinutes(60));
return "$source->html_url/$installation_path/$name/installations/new?".http_build_query(['state' => $state]); return rtrim($source->html_url, '/')."/{$path}/installations/new?".http_build_query(['state' => $state]);
} }
function getPermissionsPath(GithubApp $source) function getPermissionsPath(GithubApp $source)
{ {
$github = GithubApp::where('uuid', $source->uuid)->first(); $name = encodeGithubPathSegment(Str::kebab($source->name));
$name = str(Str::kebab($github->name)); $organization = normalizeGithubOrganization($source->organization);
return "$github->html_url/settings/apps/$name/permissions"; if (filled($organization)) {
return rtrim($source->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}/permissions";
}
return rtrim($source->html_url, '/')."/settings/apps/{$name}/permissions";
} }
function loadRepositoryByPage(GithubApp $source, string $token, int $page) function loadRepositoryByPage(GithubApp $source, string $token, int $page)
@ -189,7 +409,6 @@ function getGithubCommitRangeFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray(); return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) { } catch (Exception $e) {
ray('Error fetching GitHub commit range files: '.$e->getMessage());
return []; return [];
} }
@ -215,7 +434,6 @@ function getGithubPullRequestFiles(?GithubApp $source, string $owner, string $re
return $files->pluck('filename')->filter()->values()->toArray(); return $files->pluck('filename')->filter()->values()->toArray();
} catch (Exception $e) { } catch (Exception $e) {
ray('Error fetching GitHub PR files: '.$e->getMessage());
return []; return [];
} }

View file

@ -18,8 +18,7 @@ function send_internal_notification(string $message): void
try { try {
$team = Team::find(0); $team = Team::find(0);
$team?->notify(new GeneralNotification($message)); $team?->notify(new GeneralNotification($message));
} catch (\Throwable $e) { } catch (Throwable) {
ray($e->getMessage());
} }
} }

View file

@ -370,8 +370,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$pullRequestId = $pull_request_id; $pullRequestId = $pull_request_id;
$isPullRequest = $pullRequestId == 0 ? false : true; $isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server'); $server = data_get($resource, 'destination.server');
$fileStorages = $resource->fileStorages();
try { try {
$yaml = Yaml::parse($compose); $yaml = Yaml::parse($compose);
} catch (Exception) { } catch (Exception) {
@ -503,6 +501,40 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'is_preview' => false, 'is_preview' => false,
]); ]);
} }
}
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
$parsed = parseServiceEnvironmentVariable($key->value());
$normalizedServiceName = str($parsed['service_name'])->replace('-', '_')->replace('.', '_')->value();
$serviceExists = false;
foreach (array_keys($services) as $serviceNameKey) {
if (str($serviceNameKey)->replace('-', '_')->replace('.', '_')->value() === $normalizedServiceName) {
$serviceExists = true;
break;
}
}
if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($normalizedServiceName), 'domain');
if (is_null($domainExists)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Illuminate\Support\Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$domainValue = "$domainValue$path";
}
}
if ($parsed['port'] && is_numeric($parsed['port'])) {
$domainValue = "$domainValue:{$parsed['port']}";
}
$domains->put($normalizedServiceName, ['domain' => $domainValue]);
$resource->docker_compose_domains = $domains->toJson();
$resource->save();
}
}
} }
} }
@ -610,7 +642,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// Only add domain if the service exists // Only add domain if the service exists
if ($serviceExists) { if ($serviceExists) {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains'))) ?? collect([]); $domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]'));
$domainExists = data_get($domains->get($serviceName), 'domain'); $domainExists = data_get($domains->get($serviceName), 'domain');
// Update domain using URL with port if applicable // Update domain using URL with port if applicable
@ -703,14 +735,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$source = $parsed['source']; $source = $parsed['source'];
$target = $parsed['target']; $target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed // Mode is available in $parsed['mode'] if needed
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) { if (sourceIsLocal($source)) {
$type = str('bind'); $type = str('bind');
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $content = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$isDirectory = data_get($foundConfig, 'is_directory'); $isDirectory = data_get($foundConfig, 'is_directory');
} else { } else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory // By default, we cannot determine if the bind is a directory or not, so we set it to directory
@ -756,12 +785,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} }
} }
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $content = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$isDirectory = data_get($foundConfig, 'is_directory'); $isDirectory = data_get($foundConfig, 'is_directory');
} else { } else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
@ -1488,9 +1514,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
} }
} }
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (Exception $e) { } catch (Exception) {
// If parsing fails, keep the original docker_compose_raw unchanged // If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
} }
data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables');
@ -2070,7 +2095,6 @@ function serviceParser(Service $resource): Collection
'service_id' => $resource->id, 'service_id' => $resource->id,
]); ]);
} }
$fileStorages = $savedService->fileStorages();
if ($savedService->image !== $image) { if ($savedService->image !== $image) {
$savedService->image = $image; $savedService->image = $image;
$savedService->save(); $savedService->save();
@ -2090,14 +2114,11 @@ function serviceParser(Service $resource): Collection
$source = $parsed['source']; $source = $parsed['source'];
$target = $parsed['target']; $target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed // Mode is available in $parsed['mode'] if needed
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) { if (sourceIsLocal($source)) {
$type = str('bind'); $type = str('bind');
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $content = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$isDirectory = data_get($foundConfig, 'is_directory'); $isDirectory = data_get($foundConfig, 'is_directory');
} else { } else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory // By default, we cannot determine if the bind is a directory or not, so we set it to directory
@ -2143,12 +2164,9 @@ function serviceParser(Service $resource): Collection
} }
} }
$foundConfig = $fileStorages->whereMountPath($target)->first(); $foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) { if ($foundConfig) {
$contentNotNull_temp = data_get($foundConfig, 'content'); $content = data_get($foundConfig, 'content');
if ($contentNotNull_temp) {
$content = $contentNotNull_temp;
}
$isDirectory = data_get($foundConfig, 'is_directory'); $isDirectory = data_get($foundConfig, 'is_directory');
} else { } else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory // if isDirectory is not set (or false) & content is also not set, we assume it is a directory
@ -2747,7 +2765,6 @@ function serviceParser(Service $resource): Collection
$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);
} catch (Exception $e) { } catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged // If parsing fails, keep the original docker_compose_raw unchanged
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
} }
data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables');

View file

@ -1764,7 +1764,6 @@ function validateDNSEntry(string $fqdn, Server $server)
$query = new DNSQuery($dns_server); $query = new DNSQuery($dns_server);
$results = $query->query($host, $type); $results = $query->query($host, $type);
if ($results === false || $query->hasError()) { if ($results === false || $query->hasError()) {
ray('Error: '.$query->getLasterror());
} else { } else {
foreach ($results as $result) { foreach ($results as $result) {
if ($result->getType() == $type) { if ($result->getType() == $type) {
@ -3749,6 +3748,27 @@ function redirectRoute(Component $component, string $name, array $parameters = [
return $component->redirectRoute($name, $parameters, navigate: $navigate); return $component->redirectRoute($name, $parameters, navigate: $navigate);
} }
function coolifyRegistryUrl(): string
{
try {
return instanceSettings()->docker_registry_url ?: 'docker.io';
} catch (Throwable) {
return config('constants.coolify.registry_url', 'docker.io');
}
}
function coolifyHelperImage(): string
{
$configuredHelperImage = config('constants.coolify.helper_image');
$configuredDefaultHelperImage = config('constants.coolify.registry_url', 'docker.io').'/coollabsio/coolify-helper';
if ($configuredHelperImage !== $configuredDefaultHelperImage) {
return $configuredHelperImage;
}
return coolifyRegistryUrl().'/coollabsio/coolify-helper';
}
function getHelperVersion(): string function getHelperVersion(): string
{ {
$settings = instanceSettings(); $settings = instanceSettings();
@ -3766,9 +3786,6 @@ function loggy($message = null, array $context = [])
if (! isDev()) { if (! isDev()) {
return; return;
} }
if (function_exists('ray') && config('app.debug')) {
ray($message, $context);
}
if (is_null($message)) { if (is_null($message)) {
return app('log'); return app('log');
} }

View file

@ -50,7 +50,6 @@
"spatie/laravel-activitylog": "^4.11.0", "spatie/laravel-activitylog": "^4.11.0",
"spatie/laravel-data": "^4.19.1", "spatie/laravel-data": "^4.19.1",
"spatie/laravel-markdown": "^2.7.1", "spatie/laravel-markdown": "^2.7.1",
"spatie/laravel-ray": "^1.43.5",
"spatie/laravel-schemaless-attributes": "^2.5.1", "spatie/laravel-schemaless-attributes": "^2.5.1",
"spatie/url": "^2.4", "spatie/url": "^2.4",
"stevebauman/purify": "^6.3.1", "stevebauman/purify": "^6.3.1",

792
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "64b77285a7140ce68e83db2659e9a21d", "content-hash": "dff5cce0f3fe7ba422d9d121cce7c082",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -4753,134 +4753,6 @@
}, },
"time": "2020-10-15T08:29:30+00:00" "time": "2020-10-15T08:29:30+00:00"
}, },
{
"name": "php-di/invoker",
"version": "2.3.7",
"source": {
"type": "git",
"url": "https://github.com/PHP-DI/Invoker.git",
"reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
"reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
"shasum": ""
},
"require": {
"php": ">=7.3",
"psr/container": "^1.0|^2.0"
},
"require-dev": {
"athletic/athletic": "~0.1.8",
"mnapoli/hard-mode": "~0.3.0",
"phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
},
"type": "library",
"autoload": {
"psr-4": {
"Invoker\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "Generic and extensible callable invoker",
"homepage": "https://github.com/PHP-DI/Invoker",
"keywords": [
"callable",
"dependency",
"dependency-injection",
"injection",
"invoke",
"invoker"
],
"support": {
"issues": "https://github.com/PHP-DI/Invoker/issues",
"source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
}
],
"time": "2025-08-30T10:22:22+00:00"
},
{
"name": "php-di/php-di",
"version": "7.1.1",
"source": {
"type": "git",
"url": "https://github.com/PHP-DI/PHP-DI.git",
"reference": "f88054cc052e40dbe7b383c8817c19442d480352"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
"reference": "f88054cc052e40dbe7b383c8817c19442d480352",
"shasum": ""
},
"require": {
"laravel/serializable-closure": "^1.0 || ^2.0",
"php": ">=8.0",
"php-di/invoker": "^2.0",
"psr/container": "^1.1 || ^2.0"
},
"provide": {
"psr/container-implementation": "^1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3",
"friendsofphp/proxy-manager-lts": "^1",
"mnapoli/phpunit-easymock": "^1.3",
"phpunit/phpunit": "^9.6 || ^10 || ^11",
"vimeo/psalm": "^5|^6"
},
"suggest": {
"friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"DI\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The dependency injection container for humans",
"homepage": "https://php-di.org/",
"keywords": [
"PSR-11",
"container",
"container-interop",
"dependency injection",
"di",
"ioc",
"psr11"
],
"support": {
"issues": "https://github.com/PHP-DI/PHP-DI/issues",
"source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
},
"funding": [
{
"url": "https://github.com/mnapoli",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
"type": "tidelift"
}
],
"time": "2025-08-16T11:10:48+00:00"
},
{ {
"name": "phpdocumentor/reflection-common", "name": "phpdocumentor/reflection-common",
"version": "2.2.0", "version": "2.2.0",
@ -7026,70 +6898,6 @@
}, },
"time": "2024-11-07T21:57:40+00:00" "time": "2024-11-07T21:57:40+00:00"
}, },
{
"name": "spatie/backtrace",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8.0"
},
"require-dev": {
"ext-json": "*",
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
"symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Backtrace\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van de Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A better backtrace",
"homepage": "https://github.com/spatie/backtrace",
"keywords": [
"Backtrace",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/backtrace/issues",
"source": "https://github.com/spatie/backtrace/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2026-03-11T13:48:28+00:00"
},
{ {
"name": "spatie/commonmark-shiki-highlighter", "name": "spatie/commonmark-shiki-highlighter",
"version": "2.5.2", "version": "2.5.2",
@ -7462,95 +7270,6 @@
], ],
"time": "2026-05-19T14:06:37+00:00" "time": "2026-05-19T14:06:37+00:00"
}, },
{
"name": "spatie/laravel-ray",
"version": "1.43.9",
"source": {
"type": "git",
"url": "https://github.com/spatie/laravel-ray.git",
"reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/laravel-ray/zipball/85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
"reference": "85137a6ea1d3ecd5ad3adcb43512fff9a5529e72",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.2",
"ext-json": "*",
"illuminate/contracts": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/queue": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
"php": "^7.4|^8.0",
"spatie/backtrace": "^1.7.1",
"spatie/ray": "^1.45.0",
"symfony/stopwatch": "4.2|^5.1|^6.0|^7.0|^8.0",
"zbateson/mail-mime-parser": "^1.3.1|^2.0|^3.0|^4.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.3",
"laravel/framework": "^7.20|^8.19|^9.0|^10.0|^11.0|^12.0|^13.0",
"laravel/pint": "^1.29",
"orchestra/testbench-core": "^5.0|^6.0|^7.0|^8.0|^9.0|^10.0|^11.0",
"pestphp/pest": "^1.22|^2.0|^3.0|^4.0",
"phpstan/phpstan": "^1.10.57|^2.0.2",
"phpunit/phpunit": "^9.3|^10.1|^11.0.10|^12.4",
"rector/rector": "^0.19.2|^1.0.1|^2.0.0",
"spatie/pest-plugin-snapshots": "^1.1|^2.0",
"symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Spatie\\LaravelRay\\RayServiceProvider"
]
},
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Spatie\\LaravelRay\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Easily debug Laravel apps",
"homepage": "https://github.com/spatie/laravel-ray",
"keywords": [
"laravel-ray",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/laravel-ray/issues",
"source": "https://github.com/spatie/laravel-ray/tree/1.43.9"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2026-04-28T06:07:04+00:00"
},
{ {
"name": "spatie/laravel-schemaless-attributes", "name": "spatie/laravel-schemaless-attributes",
"version": "2.6.0", "version": "2.6.0",
@ -7757,91 +7476,6 @@
], ],
"time": "2026-04-28T06:26:02+00:00" "time": "2026-04-28T06:26:02+00:00"
}, },
{
"name": "spatie/ray",
"version": "1.48.0",
"source": {
"type": "git",
"url": "https://github.com/spatie/ray.git",
"reference": "974ac9c6e315033ab8ace883d60e094522f88ede"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/ray/zipball/974ac9c6e315033ab8ace883d60e094522f88ede",
"reference": "974ac9c6e315033ab8ace883d60e094522f88ede",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"php": "^7.4|^8.0",
"ramsey/uuid": "^3.0|^4.1",
"spatie/backtrace": "^1.7.1",
"spatie/macroable": "^1.0|^2.0",
"symfony/stopwatch": "^4.2|^5.1|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^4.2|^5.1|^6.0|^7.0.3|^8.0"
},
"require-dev": {
"illuminate/support": "^7.20|^8.18|^9.0|^10.0|^11.0|^12.0|^13.0",
"nesbot/carbon": "^2.63|^3.8.4",
"pestphp/pest": "^1.22",
"phpstan/phpstan": "^1.10.57|^2.0.3",
"phpunit/phpunit": "^9.5",
"rector/rector": "^0.19.2|^1.0.1|^2.0.0",
"spatie/phpunit-snapshot-assertions": "^4.2",
"spatie/test-time": "^1.2"
},
"bin": [
"bin/remove-ray.sh"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "1.x-dev"
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Spatie\\Ray\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van der Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "Debug with Ray to fix problems faster",
"homepage": "https://github.com/spatie/ray",
"keywords": [
"ray",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/ray/issues",
"source": "https://github.com/spatie/ray/tree/1.48.0"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2026-03-31T12:44:31+00:00"
},
{ {
"name": "spatie/shiki-php", "name": "spatie/shiki-php",
"version": "2.4.0", "version": "2.4.0",
@ -9503,90 +9137,6 @@
], ],
"time": "2026-04-10T16:19:22+00:00" "time": "2026-04-10T16:19:22+00:00"
}, },
{
"name": "symfony/polyfill-iconv",
"version": "v1.37.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-iconv.git",
"reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/2c5729fd241b4b22f6e4b436bc3354a4f262df57",
"reference": "2c5729fd241b4b22f6e4b436bc3354a4f262df57",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"provide": {
"ext-iconv": "*"
},
"suggest": {
"ext-iconv": "For best performance"
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Iconv\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for the Iconv extension",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"iconv",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-iconv/tree/v1.37.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
},
{ {
"name": "symfony/polyfill-intl-grapheme", "name": "symfony/polyfill-intl-grapheme",
"version": "v1.38.1", "version": "v1.38.1",
@ -10922,72 +10472,6 @@
], ],
"time": "2026-03-28T09:44:51+00:00" "time": "2026-03-28T09:44:51+00:00"
}, },
{
"name": "symfony/stopwatch",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/stopwatch.git",
"reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
"reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3",
"shasum": ""
},
"require": {
"php": ">=8.4",
"symfony/service-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Stopwatch\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides a way to profile code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/stopwatch/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{ {
"name": "symfony/string", "name": "symfony/string",
"version": "v8.0.13", "version": "v8.0.13",
@ -12192,214 +11676,6 @@
}, },
"time": "2018-08-08T15:08:14+00:00" "time": "2018-08-08T15:08:14+00:00"
}, },
{
"name": "zbateson/mail-mime-parser",
"version": "4.0.1",
"source": {
"type": "git",
"url": "https://github.com/zbateson/mail-mime-parser.git",
"reference": "3db681988a48fdffdba551dcc6b2f4c2da574540"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zbateson/mail-mime-parser/zipball/3db681988a48fdffdba551dcc6b2f4c2da574540",
"reference": "3db681988a48fdffdba551dcc6b2f4c2da574540",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.5",
"php": ">=8.1",
"php-di/php-di": "^6.0|^7.0",
"psr/log": "^1|^2|^3",
"zbateson/mb-wrapper": "^2.0 || ^3.0",
"zbateson/stream-decorators": "^2.1 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.0",
"monolog/monolog": "^2|^3",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^10.5"
},
"suggest": {
"ext-iconv": "For best support/performance",
"ext-mbstring": "For best support/performance"
},
"type": "library",
"autoload": {
"psr-4": {
"ZBateson\\MailMimeParser\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Zaahid Bateson"
},
{
"name": "Contributors",
"homepage": "https://github.com/zbateson/mail-mime-parser/graphs/contributors"
}
],
"description": "MIME email message parser",
"homepage": "https://mail-mime-parser.org",
"keywords": [
"MimeMailParser",
"email",
"mail",
"mailparse",
"mime",
"mimeparse",
"parser",
"php-imap"
],
"support": {
"docs": "https://mail-mime-parser.org/#usage-guide",
"issues": "https://github.com/zbateson/mail-mime-parser/issues",
"source": "https://github.com/zbateson/mail-mime-parser"
},
"funding": [
{
"url": "https://github.com/zbateson",
"type": "github"
}
],
"time": "2026-03-11T18:03:41+00:00"
},
{
"name": "zbateson/mb-wrapper",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/zbateson/mb-wrapper.git",
"reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zbateson/mb-wrapper/zipball/f0ee6af2712e92e52ee2552588cd69d21ab3363f",
"reference": "f0ee6af2712e92e52ee2552588cd69d21ab3363f",
"shasum": ""
},
"require": {
"php": ">=8.1",
"symfony/polyfill-iconv": "^1.9",
"symfony/polyfill-mbstring": "^1.9"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0|^11.0"
},
"suggest": {
"ext-iconv": "For best support/performance",
"ext-mbstring": "For best support/performance"
},
"type": "library",
"autoload": {
"psr-4": {
"ZBateson\\MbWrapper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Zaahid Bateson"
}
],
"description": "Wrapper for mbstring with fallback to iconv for encoding conversion and string manipulation",
"keywords": [
"charset",
"encoding",
"http",
"iconv",
"mail",
"mb",
"mb_convert_encoding",
"mbstring",
"mime",
"multibyte",
"string"
],
"support": {
"issues": "https://github.com/zbateson/mb-wrapper/issues",
"source": "https://github.com/zbateson/mb-wrapper/tree/3.0.0"
},
"funding": [
{
"url": "https://github.com/zbateson",
"type": "github"
}
],
"time": "2026-02-13T19:33:26+00:00"
},
{
"name": "zbateson/stream-decorators",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/zbateson/stream-decorators.git",
"reference": "0c0e79a8c960055c0e2710357098eedc07e6697a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zbateson/stream-decorators/zipball/0c0e79a8c960055c0e2710357098eedc07e6697a",
"reference": "0c0e79a8c960055c0e2710357098eedc07e6697a",
"shasum": ""
},
"require": {
"guzzlehttp/psr7": "^2.5",
"php": ">=8.1",
"zbateson/mb-wrapper": "^2.0 || ^3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "*",
"phpstan/phpstan": "*",
"phpunit/phpunit": "^10.0 || ^11.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZBateson\\StreamDecorators\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-2-Clause"
],
"authors": [
{
"name": "Zaahid Bateson"
}
],
"description": "PHP psr7 stream decorators for mime message part streams",
"keywords": [
"base64",
"charset",
"decorators",
"mail",
"mime",
"psr7",
"quoted-printable",
"stream",
"uuencode"
],
"support": {
"issues": "https://github.com/zbateson/stream-decorators/issues",
"source": "https://github.com/zbateson/stream-decorators/tree/3.0.0"
},
"funding": [
{
"url": "https://github.com/zbateson",
"type": "github"
}
],
"time": "2026-02-13T19:45:34+00:00"
},
{ {
"name": "zircote/swagger-php", "name": "zircote/swagger-php",
"version": "5.8.3", "version": "5.8.3",
@ -17401,6 +16677,70 @@
], ],
"time": "2026-04-16T21:33:58+00:00" "time": "2026-04-16T21:33:58+00:00"
}, },
{
"name": "spatie/backtrace",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/spatie/backtrace.git",
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc",
"shasum": ""
},
"require": {
"php": "^7.3 || ^8.0"
},
"require-dev": {
"ext-json": "*",
"laravel/serializable-closure": "^1.3 || ^2.0",
"phpunit/phpunit": "^9.3 || ^11.4.3",
"spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6",
"symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Spatie\\Backtrace\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Freek Van de Herten",
"email": "freek@spatie.be",
"homepage": "https://spatie.be",
"role": "Developer"
}
],
"description": "A better backtrace",
"homepage": "https://github.com/spatie/backtrace",
"keywords": [
"Backtrace",
"spatie"
],
"support": {
"issues": "https://github.com/spatie/backtrace/issues",
"source": "https://github.com/spatie/backtrace/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/sponsors/spatie",
"type": "github"
},
{
"url": "https://spatie.be/open-source/support-us",
"type": "other"
}
],
"time": "2026-03-11T13:48:28+00:00"
},
{ {
"name": "spatie/error-solutions", "name": "spatie/error-solutions",
"version": "1.1.3", "version": "1.1.3",
@ -18076,5 +17416,5 @@
"php": "^8.4" "php": "^8.4"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

View file

@ -9,9 +9,9 @@
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'), 'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'), 'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
'registry_url' => env('REGISTRY_URL', 'ghcr.io'), 'registry_url' => env('REGISTRY_URL', 'docker.io'),
'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-helper'), 'helper_image' => env('HELPER_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-helper'),
'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'ghcr.io').'/coollabsio/coolify-realtime'), 'realtime_image' => env('REALTIME_IMAGE', env('REGISTRY_URL', 'docker.io').'/coollabsio/coolify-realtime'),
'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false), 'is_windows_docker_desktop' => env('IS_WINDOWS_DOCKER_DESKTOP', false),
'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'), 'cdn_url' => env('CDN_URL', 'https://cdn.coollabs.io'),
'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'), 'versions_url' => env('VERSIONS_URL', env('CDN_URL', 'https://cdn.coollabs.io').'/coolify/versions.json'),
@ -86,7 +86,6 @@
'invitation' => [ 'invitation' => [
'link' => [ 'link' => [
'base_url' => '/invitations/',
'expiration_days' => 3, 'expiration_days' => 3,
], ],
], ],

View file

@ -1,108 +0,0 @@
<?php
return [
/*
* This setting controls whether data should be sent to Ray.
*
* By default, `ray()` will only transmit data in non-production environments.
*/
'enable' => env('RAY_ENABLED', true),
/*
* When enabled, all cache events will automatically be sent to Ray.
*/
'send_cache_to_ray' => env('SEND_CACHE_TO_RAY', false),
/*
* When enabled, all things passed to `dump` or `dd`
* will be sent to Ray as well.
*/
'send_dumps_to_ray' => env('SEND_DUMPS_TO_RAY', true),
/*
* When enabled all job events will automatically be sent to Ray.
*/
'send_jobs_to_ray' => env('SEND_JOBS_TO_RAY', false),
/*
* When enabled, all things logged to the application log
* will be sent to Ray as well.
*/
'send_log_calls_to_ray' => env('SEND_LOG_CALLS_TO_RAY', true),
/*
* When enabled, all queries will automatically be sent to Ray.
*/
'send_queries_to_ray' => env('SEND_QUERIES_TO_RAY', false),
/**
* When enabled, all duplicate queries will automatically be sent to Ray.
*/
'send_duplicate_queries_to_ray' => env('SEND_DUPLICATE_QUERIES_TO_RAY', false),
/*
* When enabled, slow queries will automatically be sent to Ray.
*/
'send_slow_queries_to_ray' => env('SEND_SLOW_QUERIES_TO_RAY', false),
/**
* Queries that are longer than this number of milliseconds will be regarded as slow.
*/
'slow_query_threshold_in_ms' => env('RAY_SLOW_QUERY_THRESHOLD_IN_MS', 500),
/*
* When enabled, all requests made to this app will automatically be sent to Ray.
*/
'send_requests_to_ray' => env('SEND_REQUESTS_TO_RAY', false),
/**
* When enabled, all Http Client requests made by this app will be automatically sent to Ray.
*/
'send_http_client_requests_to_ray' => env('SEND_HTTP_CLIENT_REQUESTS_TO_RAY', false),
/*
* When enabled, all views that are rendered automatically be sent to Ray.
*/
'send_views_to_ray' => env('SEND_VIEWS_TO_RAY', false),
/*
* When enabled, all exceptions will be automatically sent to Ray.
*/
'send_exceptions_to_ray' => env('SEND_EXCEPTIONS_TO_RAY', true),
/*
* When enabled, all deprecation notices will be automatically sent to Ray.
*/
'send_deprecated_notices_to_ray' => env('SEND_DEPRECATED_NOTICES_TO_RAY', false),
/*
* The host used to communicate with the Ray app.
* When using Docker on Mac or Windows, you can replace localhost with 'host.docker.internal'
* When using Docker on Linux, you can replace localhost with '172.17.0.1'
* When using Homestead with the VirtualBox provider, you can replace localhost with '10.0.2.2'
* When using Homestead with the Parallels provider, you can replace localhost with '10.211.55.2'
*/
'host' => env('RAY_HOST', 'host.docker.internal'),
/*
* The port number used to communicate with the Ray app.
*/
'port' => env('RAY_PORT', 23517),
/*
* Absolute base path for your sites or projects in Homestead,
* Vagrant, Docker, or another remote development server.
*/
'remote_path' => env('RAY_REMOTE_PATH', null),
/*
* Absolute base path for your sites or projects on your local
* computer where your IDE or code editor is running on.
*/
'local_path' => env('RAY_LOCAL_PATH', null),
/*
* When this setting is enabled, the package will not try to format values sent to Ray.
*/
'always_send_raw_values' => false,
];

View file

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->string('docker_registry_url')->default('docker.io')->after('is_auto_update_enabled');
});
}
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('docker_registry_url');
});
}
};

View file

@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->json('webhook_allowed_internal_hosts')->nullable();
$table->boolean('webhook_allow_localhost')->default(false);
});
}
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn(['webhook_allowed_internal_hosts', 'webhook_allow_localhost']);
});
}
};

View file

@ -20,14 +20,33 @@ class DevelopmentRailpackExamplesSeeder extends Seeder
{ {
public const PROJECT_UUID = 'railpack-examples'; public const PROJECT_UUID = 'railpack-examples';
public const ENVIRONMENT_UUID = 'railpack-examples-production';
public const GIT_REPOSITORY = 'coollabsio/coolify-examples'; public const GIT_REPOSITORY = 'coollabsio/coolify-examples';
public const GIT_BRANCH = 'next'; public const GIT_BRANCH = 'next';
public const REPOSITORY_PROJECT_ID = 603035348; public const REPOSITORY_PROJECT_ID = 603035348;
public const LIMA_SERVERS = [
[
'server_uuid' => 'lima-ubuntu-2404',
'server_name' => 'lima-ubuntu-2404',
'port' => 2222,
'environment_name' => 'ubuntu24',
'environment_uuid' => 'railpack-examples-ubuntu24',
'uuid_prefix' => 'ubuntu24-',
],
[
'server_uuid' => 'lima-ubuntu-2604',
'server_name' => 'lima-ubuntu-2604',
'port' => 2223,
'environment_name' => 'ubuntu26',
'environment_uuid' => 'railpack-examples-ubuntu26',
'uuid_prefix' => 'ubuntu26-',
],
];
private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000';
public function run(): void public function run(): void
{ {
if (! $this->isDevelopmentEnvironment()) { if (! $this->isDevelopmentEnvironment()) {
@ -37,16 +56,22 @@ public function run(): void
} }
$this->ensureDevelopmentPrerequisitesExist(); $this->ensureDevelopmentPrerequisitesExist();
$destination = StandaloneDocker::query()->find(0);
if (! $destination) { if (! StandaloneDocker::query()->find(0)) {
throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.'); throw new RuntimeException('StandaloneDocker with id=0 is required before running DevelopmentRailpackExamplesSeeder.');
} }
$environment = $this->prepareEnvironment(); $this->cleanupLegacyLimaProjects();
$this->cleanupLegacyProductionExamples();
foreach (self::examples() as $example) { foreach (self::LIMA_SERVERS as $limaServer) {
$this->upsertApplication($environment, $destination, $example); $this->seedEnvironment(
environmentUuid: $limaServer['environment_uuid'],
environmentName: $limaServer['environment_name'],
destination: $this->limaDestination($limaServer['server_uuid']),
uuidPrefix: $limaServer['uuid_prefix'],
nameSuffix: " ({$limaServer['environment_name']})",
);
} }
} }
@ -440,6 +465,37 @@ private function ensureDevelopmentPrerequisitesExist(): void
], ],
); );
foreach (self::LIMA_SERVERS as $limaServer) {
$server = Server::query()->firstOrCreate(
['uuid' => $limaServer['server_uuid']],
[
'name' => $limaServer['server_name'],
'description' => 'This is a Lima VM for local development testing',
'ip' => 'host.docker.internal',
'port' => $limaServer['port'],
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
],
);
$server->settings->forceFill([
'sentinel_custom_url' => self::LIMA_SENTINEL_URL,
])->saveQuietly();
StandaloneDocker::query()->firstOrCreate(
['server_id' => $server->id],
[
'uuid' => "{$limaServer['server_uuid']}-docker",
'name' => "{$limaServer['server_name']} Docker",
'network' => 'coolify',
],
);
}
StandaloneDocker::query()->firstOrCreate( StandaloneDocker::query()->firstOrCreate(
['id' => 0], ['id' => 0],
[ [
@ -489,7 +545,71 @@ private function isDevelopmentEnvironment(): bool
return in_array(config('app.env'), ['local', 'development', 'dev'], true); return in_array(config('app.env'), ['local', 'development', 'dev'], true);
} }
private function prepareEnvironment(): Environment private function limaDestination(string $serverUuid): StandaloneDocker
{
$limaDestination = Server::query()
->where('uuid', $serverUuid)
->first()
?->standaloneDockers()
->first();
if (! $limaDestination) {
throw new RuntimeException("Lima StandaloneDocker destination is required for {$serverUuid} before running DevelopmentRailpackExamplesSeeder.");
}
return $limaDestination;
}
private function cleanupLegacyLimaProjects(): void
{
Project::query()
->whereIn('uuid', [
'railpack-examples-lima-ubuntu-2404',
'railpack-examples-lima-ubuntu-2604',
])
->get()
->each(function (Project $project): void {
Application::withTrashed()
->whereIn('environment_id', $project->environments()->pluck('id'))
->get()
->each
->forceDelete();
$project->delete();
});
}
private function cleanupLegacyProductionExamples(): void
{
$project = Project::query()->where('uuid', self::PROJECT_UUID)->first();
if (! $project) {
return;
}
Application::withTrashed()
->whereIn('environment_id', $project->environments()->pluck('id'))
->whereIn('uuid', collect(self::examples())->pluck('uuid'))
->get()
->each
->forceDelete();
}
private function seedEnvironment(
string $environmentUuid,
string $environmentName,
StandaloneDocker $destination,
string $uuidPrefix = '',
string $nameSuffix = '',
): void {
$environment = $this->prepareEnvironment($environmentUuid, $environmentName);
foreach (self::examples() as $example) {
$this->upsertApplication($environment, $destination, $example, $uuidPrefix, $nameSuffix);
}
}
private function prepareEnvironment(string $environmentUuid, string $environmentName): Environment
{ {
$project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]); $project = Project::query()->firstOrNew(['uuid' => self::PROJECT_UUID]);
$project->fill([ $project->fill([
@ -499,17 +619,29 @@ private function prepareEnvironment(): Environment
]); ]);
$project->save(); $project->save();
$environment = $project->environments()->first(); $environment = $project->environments()
->where(function ($query) use ($environmentName, $environmentUuid): void {
$query
->where('name', $environmentName)
->orWhere('uuid', $environmentUuid);
})
->first();
$existingEnvironment = $project->environments()->first();
if (! $environment && $project->environments()->count() === 1 && $existingEnvironment?->name === 'production') {
$environment = $existingEnvironment;
}
if (! $environment) { if (! $environment) {
$environment = $project->environments()->create([ $environment = $project->environments()->create([
'name' => 'production', 'name' => $environmentName,
'uuid' => self::ENVIRONMENT_UUID, 'uuid' => $environmentUuid,
]); ]);
} else { } else {
$environment->update([ $environment->update([
'name' => 'production', 'name' => $environmentName,
'uuid' => self::ENVIRONMENT_UUID, 'uuid' => $environmentUuid,
]); ]);
} }
@ -519,13 +651,15 @@ private function prepareEnvironment(): Environment
/** /**
* @param array<string, mixed> $example * @param array<string, mixed> $example
*/ */
private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example): void private function upsertApplication(Environment $environment, StandaloneDocker $destination, array $example, string $uuidPrefix = '', string $nameSuffix = ''): void
{ {
$application = Application::withTrashed()->firstOrNew(['uuid' => $example['uuid']]); $uuid = $uuidPrefix.$example['uuid'];
$name = $example['name'].$nameSuffix;
$application = Application::withTrashed()->firstOrNew(['uuid' => $uuid]);
$application->fill([ $application->fill([
'name' => $example['name'], 'name' => $name,
'description' => $example['name'], 'description' => $name,
'fqdn' => "http://{$example['uuid']}.127.0.0.1.sslip.io", 'fqdn' => "http://{$uuid}.127.0.0.1.sslip.io",
'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID, 'repository_project_id' => $example['repository_project_id'] ?? self::REPOSITORY_PROJECT_ID,
'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY, 'git_repository' => $example['git_repository'] ?? self::GIT_REPOSITORY,
'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH, 'git_branch' => $example['git_branch'] ?? self::GIT_BRANCH,

View file

@ -7,6 +7,11 @@
class ProjectSeeder extends Seeder class ProjectSeeder extends Seeder
{ {
private const LIMA_ENVIRONMENTS = [
['name' => 'ubuntu24', 'uuid' => 'ubuntu24'],
['name' => 'ubuntu26', 'uuid' => 'ubuntu26'],
];
public function run(): void public function run(): void
{ {
$project = Project::create([ $project = Project::create([
@ -16,7 +21,14 @@ public function run(): void
'team_id' => 0, 'team_id' => 0,
]); ]);
// Update the auto-created environment with a deterministic UUID foreach (self::LIMA_ENVIRONMENTS as $index => $environment) {
$project->environments()->first()->update(['uuid' => 'production']); if ($index === 0) {
$project->environments()->first()->update($environment);
continue;
}
$project->environments()->create($environment);
}
} }
} }

View file

@ -9,6 +9,13 @@
class ServerSeeder extends Seeder class ServerSeeder extends Seeder
{ {
private const LIMA_SENTINEL_URL = 'http://host.lima.internal:8000';
private const LIMA_SERVERS = [
['uuid' => 'lima-ubuntu-2404', 'name' => 'lima-ubuntu-2404', 'port' => 2222],
['uuid' => 'lima-ubuntu-2604', 'name' => 'lima-ubuntu-2604', 'port' => 2223],
];
public function run(): void public function run(): void
{ {
Server::create([ Server::create([
@ -24,5 +31,25 @@ public function run(): void
'status' => ProxyStatus::EXITED->value, 'status' => ProxyStatus::EXITED->value,
], ],
]); ]);
foreach (self::LIMA_SERVERS as $limaServer) {
$server = Server::create([
'uuid' => $limaServer['uuid'],
'name' => $limaServer['name'],
'description' => 'This is a Lima VM for local development testing',
'ip' => 'host.docker.internal',
'port' => $limaServer['port'],
'team_id' => 0,
'private_key_id' => 1,
'proxy' => [
'type' => ProxyTypes::TRAEFIK->value,
'status' => ProxyStatus::EXITED->value,
],
]);
$server->settings->forceFill([
'sentinel_custom_url' => self::LIMA_SENTINEL_URL,
])->saveQuietly();
}
} }
} }

View file

@ -10,6 +10,8 @@ services:
- GROUP_ID=${GROUPID:-1000} - GROUP_ID=${GROUPID:-1000}
ports: ports:
- "${APP_PORT:-8000}:8080" - "${APP_PORT:-8000}:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
AUTORUN_ENABLED: false AUTORUN_ENABLED: false
PUSHER_HOST: "${PUSHER_HOST}" PUSHER_HOST: "${PUSHER_HOST}"
@ -70,6 +72,8 @@ services:
ports: ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001" - "${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js

View file

@ -10,6 +10,8 @@ services:
- GROUP_ID=${GROUPID:-1000} - GROUP_ID=${GROUPID:-1000}
ports: ports:
- "${APP_PORT:-8000}:8080" - "${APP_PORT:-8000}:8080"
extra_hosts:
- "host.docker.internal:host-gateway"
environment: environment:
AUTORUN_ENABLED: false AUTORUN_ENABLED: false
PUSHER_HOST: "${PUSHER_HOST}" PUSHER_HOST: "${PUSHER_HOST}"
@ -70,6 +72,8 @@ services:
ports: ports:
- "${FORWARD_SOKETI_PORT:-6001}:6001" - "${FORWARD_SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
- ./storage:/var/www/html/storage - ./storage:/var/www/html/storage
- ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js

View file

@ -1,6 +1,6 @@
services: services:
coolify: coolify:
image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
volumes: volumes:
- type: bind - type: bind
source: /data/coolify/source/.env source: /data/coolify/source/.env
@ -60,7 +60,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View file

@ -1,14 +1,14 @@
services: services:
coolify-testing-host: coolify-testing-host:
init: true init: true
image: "ghcr.io/coollabsio/coolify-testing-host:latest" image: "docker.io/coollabsio/coolify-testing-host:latest"
pull_policy: always pull_policy: always
container_name: coolify-testing-host container_name: coolify-testing-host
volumes: volumes:
- //var/run/docker.sock://var/run/docker.sock - //var/run/docker.sock://var/run/docker.sock
- ./:/data/coolify - ./:/data/coolify
coolify: coolify:
image: "ghcr.io/coollabsio/coolify:latest" image: "docker.io/coollabsio/coolify:latest"
pull_policy: always pull_policy: always
container_name: coolify container_name: coolify
restart: always restart: always

View file

@ -0,0 +1,35 @@
images:
- location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-amd64.img
arch: x86_64
- location: https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img
arch: aarch64
cpus: 2
memory: 2GiB
disk: 20GiB
containerd:
system: false
user: false
mounts: []
ssh:
localPort: 2222
loadDotSSHPubKeys: false
provision:
- mode: system
script: |
#!/bin/bash
set -euxo pipefail
install -d -m 700 /root/.ssh
cat >/root/.ssh/authorized_keys <<'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
EOF
chmod 600 /root/.ssh/authorized_keys
sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
systemctl restart ssh

View file

@ -0,0 +1,35 @@
images:
- location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-amd64.img
arch: x86_64
- location: https://cloud-images.ubuntu.com/releases/26.04/release/ubuntu-26.04-server-cloudimg-arm64.img
arch: aarch64
cpus: 2
memory: 2GiB
disk: 20GiB
containerd:
system: false
user: false
mounts: []
ssh:
localPort: 2223
loadDotSSHPubKeys: false
provision:
- mode: system
script: |
#!/bin/bash
set -euxo pipefail
install -d -m 700 /root/.ssh
cat >/root/.ssh/authorized_keys <<'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68 sail@76ff66d2e2dd
EOF
chmod 600 /root/.ssh/authorized_keys
sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config
sed -i 's/^#\?PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config
systemctl restart ssh

View file

@ -165,6 +165,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"static_image": { "static_image": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -615,6 +619,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"static_image": { "static_image": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1065,6 +1073,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"static_image": { "static_image": {
"type": "string", "type": "string",
"enum": [ "enum": [
@ -1626,6 +1638,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"use_build_server": { "use_build_server": {
"type": "boolean", "type": "boolean",
"nullable": true, "nullable": true,
@ -1961,6 +1977,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"use_build_server": { "use_build_server": {
"type": "boolean", "type": "boolean",
"nullable": true, "nullable": true,
@ -2333,6 +2353,10 @@
"type": "boolean", "type": "boolean",
"description": "The flag to indicate if HTTPS is forced. Defaults to true." "description": "The flag to indicate if HTTPS is forced. Defaults to true."
}, },
"is_preview_deployments_enabled": {
"type": "boolean",
"description": "Enable preview deployments for pull requests."
},
"install_command": { "install_command": {
"type": "string", "type": "string",
"description": "The install command." "description": "The install command."
@ -2553,6 +2577,10 @@
"is_preserve_repository_enabled": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false."
},
"include_source_commit_in_build": {
"type": "boolean",
"description": "Include source commit information in the build. Default is false."
} }
}, },
"type": "object" "type": "object"
@ -2675,6 +2703,16 @@
"format": "int32", "format": "int32",
"default": 100 "default": 100
} }
},
{
"name": "show_timestamps",
"in": "query",
"description": "Show timestamps in the logs.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
} }
], ],
"responses": { "responses": {
@ -5952,6 +5990,80 @@
] ]
} }
}, },
"\/databases\/{uuid}\/logs": {
"get": {
"tags": [
"Databases"
],
"summary": "Get database logs.",
"description": "Get database logs by UUID.",
"operationId": "get-database-logs-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the database.",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "lines",
"in": "query",
"description": "Number of lines to show from the end of the logs.",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
},
{
"name": "show_timestamps",
"in": "query",
"description": "Show timestamps in the logs.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
"200": {
"description": "Get database logs by UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"logs": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": { "\/databases\/{uuid}\/backups\/{scheduled_backup_uuid}\/executions\/{execution_uuid}": {
"delete": { "delete": {
"tags": [ "tags": [
@ -7439,7 +7551,6 @@
"schema": { "schema": {
"required": [ "required": [
"name", "name",
"api_url",
"html_url", "html_url",
"app_id", "app_id",
"installation_id", "installation_id",
@ -11293,6 +11404,90 @@
] ]
} }
}, },
"\/services\/{uuid}\/logs": {
"get": {
"tags": [
"Services"
],
"summary": "Get service logs.",
"description": "Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET \/services\/{uuid}`.",
"operationId": "get-service-logs-by-uuid",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "UUID of the service.",
"required": true,
"schema": {
"type": "string",
"format": "uuid"
}
},
{
"name": "sub_service_name",
"in": "query",
"description": "Sub-service name from `GET \/services\/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.",
"required": true,
"schema": {
"type": "string",
"example": "appwrite-console"
}
},
{
"name": "lines",
"in": "query",
"description": "Number of lines to show from the end of the logs.",
"required": false,
"schema": {
"type": "integer",
"format": "int32",
"default": 100
}
},
{
"name": "show_timestamps",
"in": "query",
"description": "Show timestamps in the logs.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
"200": {
"description": "Get service logs by UUID.",
"content": {
"application\/json": {
"schema": {
"properties": {
"logs": {
"type": "string"
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/services\/{uuid}\/envs": { "\/services\/{uuid}\/envs": {
"get": { "get": {
"tags": [ "tags": [

View file

@ -118,6 +118,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
static_image: static_image:
type: string type: string
enum: ['nginx:alpine'] enum: ['nginx:alpine']
@ -405,6 +408,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
static_image: static_image:
type: string type: string
enum: ['nginx:alpine'] enum: ['nginx:alpine']
@ -692,6 +698,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
static_image: static_image:
type: string type: string
enum: ['nginx:alpine'] enum: ['nginx:alpine']
@ -1063,6 +1072,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
use_build_server: use_build_server:
type: boolean type: boolean
nullable: true nullable: true
@ -1277,6 +1289,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
use_build_server: use_build_server:
type: boolean type: boolean
nullable: true nullable: true
@ -1507,6 +1522,9 @@ paths:
is_force_https_enabled: is_force_https_enabled:
type: boolean type: boolean
description: 'The flag to indicate if HTTPS is forced. Defaults to true.' description: 'The flag to indicate if HTTPS is forced. Defaults to true.'
is_preview_deployments_enabled:
type: boolean
description: 'Enable preview deployments for pull requests.'
install_command: install_command:
type: string type: string
description: 'The install command.' description: 'The install command.'
@ -1663,6 +1681,9 @@ paths:
is_preserve_repository_enabled: is_preserve_repository_enabled:
type: boolean type: boolean
description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.' description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'
include_source_commit_in_build:
type: boolean
description: 'Include source commit information in the build. Default is false.'
type: object type: object
responses: responses:
'200': '200':
@ -1716,6 +1737,14 @@ paths:
type: integer type: integer
format: int32 format: int32
default: 100 default: 100
-
name: show_timestamps
in: query
description: 'Show timestamps in the logs.'
required: false
schema:
type: boolean
default: false
responses: responses:
'200': '200':
description: 'Get application logs by UUID.' description: 'Get application logs by UUID.'
@ -3908,6 +3937,57 @@ paths:
security: security:
- -
bearerAuth: [] bearerAuth: []
'/databases/{uuid}/logs':
get:
tags:
- Databases
summary: 'Get database logs.'
description: 'Get database logs by UUID.'
operationId: get-database-logs-by-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the database.'
required: true
schema:
type: string
format: uuid
-
name: lines
in: query
description: 'Number of lines to show from the end of the logs.'
required: false
schema:
type: integer
format: int32
default: 100
-
name: show_timestamps
in: query
description: 'Show timestamps in the logs.'
required: false
schema:
type: boolean
default: false
responses:
'200':
description: 'Get database logs by UUID.'
content:
application/json:
schema:
properties:
logs: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
'/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}': '/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}':
delete: delete:
tags: tags:
@ -4806,7 +4886,6 @@ paths:
schema: schema:
required: required:
- name - name
- api_url
- html_url - html_url
- app_id - app_id
- installation_id - installation_id
@ -7150,6 +7229,65 @@ paths:
security: security:
- -
bearerAuth: [] bearerAuth: []
'/services/{uuid}/logs':
get:
tags:
- Services
summary: 'Get service logs.'
description: 'Get logs for a specific service sub-resource by service UUID. The `sub_service_name` query parameter must match the `name` field of one of the service applications or databases returned by `GET /services/{uuid}`.'
operationId: get-service-logs-by-uuid
parameters:
-
name: uuid
in: path
description: 'UUID of the service.'
required: true
schema:
type: string
format: uuid
-
name: sub_service_name
in: query
description: 'Sub-service name from `GET /services/{uuid}` under `applications[].name` or `databases[].name`. Do not use `human_name` or the Docker container name with the service UUID suffix.'
required: true
schema:
type: string
example: appwrite-console
-
name: lines
in: query
description: 'Number of lines to show from the end of the logs.'
required: false
schema:
type: integer
format: int32
default: 100
-
name: show_timestamps
in: query
description: 'Show timestamps in the logs.'
required: false
schema:
type: boolean
default: false
responses:
'200':
description: 'Get service logs by UUID.'
content:
application/json:
schema:
properties:
logs: { type: string }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
'/services/{uuid}/envs': '/services/{uuid}/envs':
get: get:
tags: tags:

View file

@ -15,4 +15,4 @@ ROOT_USERNAME=
ROOT_USER_EMAIL= ROOT_USER_EMAIL=
ROOT_USER_PASSWORD= ROOT_USER_PASSWORD=
REGISTRY_URL=ghcr.io REGISTRY_URL=docker.io

View file

@ -1,6 +1,6 @@
services: services:
coolify: coolify:
image: "${REGISTRY_URL:-ghcr.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}" image: "${REGISTRY_URL:-docker.io}/coollabsio/coolify:${LATEST_IMAGE:-latest}"
volumes: volumes:
- type: bind - type: bind
source: /data/coolify/source/.env source: /data/coolify/source/.env
@ -60,7 +60,7 @@ services:
retries: 10 retries: 10
timeout: 2s timeout: 2s
soketi: soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.16' image: '${REGISTRY_URL:-docker.io}/coollabsio/coolify-realtime:1.0.16'
ports: ports:
- "${SOKETI_PORT:-6001}:6001" - "${SOKETI_PORT:-6001}:6001"
- "6002:6002" - "6002:6002"

View file

@ -1,14 +1,14 @@
services: services:
coolify-testing-host: coolify-testing-host:
init: true init: true
image: "ghcr.io/coollabsio/coolify-testing-host:latest" image: "docker.io/coollabsio/coolify-testing-host:latest"
pull_policy: always pull_policy: always
container_name: coolify-testing-host container_name: coolify-testing-host
volumes: volumes:
- //var/run/docker.sock://var/run/docker.sock - //var/run/docker.sock://var/run/docker.sock
- ./:/data/coolify - ./:/data/coolify
coolify: coolify:
image: "ghcr.io/coollabsio/coolify:latest" image: "docker.io/coollabsio/coolify:latest"
pull_policy: always pull_policy: always
container_name: coolify container_name: coolify
restart: always restart: always

View file

@ -9,7 +9,7 @@
## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) ## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24)
## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) ## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false)
## AUTOUPDATE - Set to "false" to disable auto-updates ## AUTOUPDATE - Set to "false" to disable auto-updates
## REGISTRY_URL - Custom registry URL for Docker images (default: ghcr.io) ## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io)
set -e # Exit immediately if a command exits with a non-zero status set -e # Exit immediately if a command exits with a non-zero status
## $1 could be empty, so we need to disable this check ## $1 could be empty, so we need to disable this check
@ -50,7 +50,7 @@ else
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2) REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2)
echo "Using registry URL from .env: $REGISTRY_URL" echo "Using registry URL from .env: $REGISTRY_URL"
else else
REGISTRY_URL="ghcr.io" REGISTRY_URL="docker.io"
echo "Using default registry URL: $REGISTRY_URL" echo "Using default registry URL: $REGISTRY_URL"
fi fi
fi fi
@ -920,9 +920,9 @@ echo -e " - Please wait."
getAJoke getAJoke
if [[ $- == *x* ]]; then if [[ $- == *x* ]]; then
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
else else
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
fi fi
echo " - Coolify installed successfully." echo " - Coolify installed successfully."
echo " - Waiting for Coolify to be ready..." echo " - Waiting for Coolify to be ready..."

View file

@ -4,9 +4,15 @@
CDN="https://cdn.coollabs.io/coolify-nightly" CDN="https://cdn.coollabs.io/coolify-nightly"
LATEST_IMAGE=${1:-latest} LATEST_IMAGE=${1:-latest}
LATEST_HELPER_VERSION=${2:-latest} LATEST_HELPER_VERSION=${2:-latest}
REGISTRY_URL=${3:-ghcr.io}
SKIP_BACKUP=${4:-false}
ENV_FILE="/data/coolify/source/.env" ENV_FILE="/data/coolify/source/.env"
if [ -n "${3+x}" ]; then
REGISTRY_URL="$3"
elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1)
else
REGISTRY_URL="docker.io"
fi
SKIP_BACKUP=${4:-false}
STATUS_FILE="/data/coolify/source/.upgrade-status" STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S) DATE=$(date +%Y-%m-%d-%H-%M-%S)
@ -80,7 +86,7 @@ fi
# Get all unique images from docker compose config # Get all unique images from docker compose config
# LATEST_IMAGE env var is needed for image substitution in compose files # LATEST_IMAGE env var is needed for image substitution in compose files
IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
if [ -z "$IMAGES" ]; then if [ -z "$IMAGES" ]; then
log "ERROR: Failed to extract images from docker-compose files" log "ERROR: Failed to extract images from docker-compose files"
@ -127,7 +133,21 @@ update_env_var() {
fi fi
} }
set_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
log "Updated ${key}"
else
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
log "Added ${key}"
fi
}
log "Checking environment variables..." log "Checking environment variables..."
set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
@ -164,7 +184,7 @@ echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection." echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade) # Also pull the helper image (not in compose files but needed for upgrade)
HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..." echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE" log "Pulling image: $HELPER_IMAGE"
if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then
@ -256,7 +276,7 @@ nohup bash -c "
fi fi
log 'Running docker compose up...' log 'Running docker compose up...'
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
log 'Docker compose up completed' log 'Docker compose up completed'
# Final log entry # Final log entry

View file

@ -154,7 +154,7 @@ class="text-xs text-neutral-500 dark:text-neutral-400">
<div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3"> <div class="grid justify-start grid-cols-1 gap-4 text-left xl:grid-cols-3">
<template x-for="service in filteredServices" :key="service.name"> <template x-for="service in filteredServices" :key="service.name">
<div class="relative" x-on:click="setType('one-click-service-' + service.name)" <div class="relative" x-on:click="setType('one-click-service-' + service.id)"
:class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }"> :class="{ 'cursor-pointer': !selecting, 'cursor-not-allowed opacity-50': selecting }">
<x-resource-view> <x-resource-view>
<x-slot:title> <x-slot:title>
@ -214,7 +214,7 @@ class="px-2 py-0.5 text-xs rounded-full bg-amber-100 text-amber-800 dark:bg-ambe
</div> </div>
</template> </template>
<template x-if="shouldShowDocIcon(service)"> <template x-if="shouldShowDocIcon(service)">
<a :href="getDocLink(service) || coolifyDocsUrl(service.name)" target="_blank" <a :href="getDocLink(service) || coolifyDocsUrl(service)" target="_blank"
@click.stop @mouseenter="resolveDocLink(service)" @click.stop @mouseenter="resolveDocLink(service)"
class="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-200 dark:hover:bg-coolgray-300 transition-colors" class="absolute top-2 right-2 p-1.5 rounded hover:bg-neutral-200 dark:hover:bg-coolgray-300 transition-colors"
:class="{ 'opacity-50': docCheckInProgress[service.name] }" :class="{ 'opacity-50': docCheckInProgress[service.name] }"
@ -287,8 +287,8 @@ function searchResources() {
// Remove flavor suffixes: -with-*, -without-* // Remove flavor suffixes: -with-*, -without-*
return normalized.replace(/-(with|without)-.+$/, ''); return normalized.replace(/-(with|without)-.+$/, '');
}, },
coolifyDocsUrl(serviceName) { coolifyDocsUrl(service) {
const baseName = this.extractBaseServiceName(serviceName); const baseName = service.docsSlug || this.extractBaseServiceName(service.name);
return 'https://coolify.io/docs/services/' + baseName; return 'https://coolify.io/docs/services/' + baseName;
}, },
officialDocsUrl(service) { officialDocsUrl(service) {
@ -322,7 +322,7 @@ function searchResources() {
this.docCheckInProgress[serviceName] = true; this.docCheckInProgress[serviceName] = true;
// 1. Try Coolify docs first // 1. Try Coolify docs first
const coolifyUrl = this.coolifyDocsUrl(serviceName); const coolifyUrl = this.coolifyDocsUrl(service);
const coolifyExists = await this.checkUrlExists(coolifyUrl); const coolifyExists = await this.checkUrlExists(coolifyUrl);
if (coolifyExists) { if (coolifyExists) {

View file

@ -15,7 +15,10 @@
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<h1>Server</h1> <h1>Server</h1>
@if ($server->proxySet() || $server->isSentinelEnabled()) @php
$showSentinelStatus = $server->isFunctional() && $server->isSentinelEnabled();
@endphp
@if ($server->proxySet() || $showSentinelStatus)
<div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2"> <div data-testid="server-status-summary" class="flex flex-wrap items-center gap-2">
@if ($server->proxySet()) @if ($server->proxySet())
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
@ -38,7 +41,7 @@
type="warning" /> type="warning" />
</div> </div>
@endif @endif
@if ($server->isSentinelEnabled()) @if ($showSentinelStatus)
@if ($server->isSentinelLive()) @if ($server->isSentinelLive())
<x-status-badge label="Sentinel" status="In sync" type="success" /> <x-status-badge label="Sentinel" status="In sync" type="success" />
@else @else

View file

@ -120,6 +120,19 @@ class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warn
@else @else
You can't use this server until it is validated. You can't use this server until it is validated.
@endif @endif
@if ($this->limaStartCommand)
<div
class="mt-4 rounded border border-coolgray-200 bg-coolgray-100 p-3 text-sm dark:border-coolgray-300 dark:bg-coolgray-100">
<div class="font-semibold">Start this Lima VM locally</div>
<div class="mt-1 dark:text-neutral-400">
Run this from the Coolify repository root before validating this server:
</div>
<code
class="mt-2 block overflow-x-auto rounded bg-black px-3 py-2 font-mono text-xs text-white">
{{ $this->limaStartCommand }}
</code>
</div>
@endif
@if ($isValidating) @if ($isValidating)
<div x-data="{ slideOverOpen: true }"> <div x-data="{ slideOverOpen: true }">
<x-slide-over closeWithX fullScreen> <x-slide-over closeWithX fullScreen>

View file

@ -70,6 +70,15 @@ class="flex flex-col h-full gap-8 sm:flex-row">
environments! environments!
</x-callout> </x-callout>
@endif @endif
<h4 class="pt-4">Webhook/S3 Endpoint Controls</h4>
<x-forms.textarea id="webhook_allowed_internal_hosts" rows="4"
label="Allowed Internal Webhook/S3 Targets"
helper="Optional instance-level allowlist for webhook and S3 endpoint destinations. Supports exact hostnames, IPs, and CIDR ranges separated by commas or new lines."
placeholder="hooks.company.local, 10.50.0.0/16, 192.168.10.20" />
<div class="md:w-96">
<x-forms.checkbox id="webhook_allow_localhost" label="Allow Localhost Webhook/S3 Targets"
helper="Allows localhost/loopback targets only when they are also listed above. Use only for local development or trusted single-instance setups." />
</div>
<h4 class="pt-4">MCP Server</h4> <h4 class="pt-4">MCP Server</h4>
<div class="md:w-96"> <div class="md:w-96">
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide" <x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server Instance-wide"

View file

@ -81,12 +81,6 @@ class="px-4 py-2 text-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-co
placeholder="2001:db8::1" autocomplete="new-password" /> placeholder="2001:db8::1" autocomplete="new-password" />
</div> </div>
@if($buildActivityId)
<div class="w-full mt-4">
<livewire:activity-monitor header="Building Helper Image" :activityId="$buildActivityId"
:fullHeight="false" />
</div>
@endif
@if(isDev()) @if(isDev())
<x-forms.input canGate="update" :canResource="$settings" id="dev_helper_version" label="Dev Helper Version (Development Only)" <x-forms.input canGate="update" :canResource="$settings" id="dev_helper_version" label="Dev Helper Version (Development Only)"
helper="Override the default coolify-helper image version. Leave empty to use the default version from config ({{ config('constants.coolify.helper_version') }}). Examples: 1.0.11, latest, dev" helper="Override the default coolify-helper image version. Leave empty to use the default version from config ({{ config('constants.coolify.helper_version') }}). Examples: 1.0.11, latest, dev"

View file

@ -44,6 +44,15 @@
<x-forms.input required label="Frequency (cron expression)" disabled placeholder="disabled" <x-forms.input required label="Frequency (cron expression)" disabled placeholder="disabled"
helper="Frequency (cron expression) (automatically update coolify).<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every day at 00:00" /> helper="Frequency (cron expression) (automatically update coolify).<br>You can use every_minute, hourly, daily, weekly, monthly, yearly.<br><br>Default is every day at 00:00" />
@endif @endif
<h4 class="pt-4">Docker Registry</h4>
<div class="md:w-96">
<x-forms.select id="docker_registry_url" label="Docker Registry"
helper="The Docker registry used to pull Coolify images during updates.<br>Switch to Docker Hub if you experience rate limiting with GitHub Container Registry.">
<option value="docker.io">Docker Hub (docker.io)</option>
<option value="ghcr.io">GitHub Container Registry (ghcr.io)</option>
</x-forms.select>
</div>
</div> </div>
</form> </form>

View file

@ -373,7 +373,8 @@ function createGithubApp(webhook_endpoint, use_custom_webhook_endpoint, custom_w
baseUrl = devWebhook; baseUrl = devWebhook;
} }
const webhookBaseUrl = `${baseUrl}/webhooks`; const webhookBaseUrl = `${baseUrl}/webhooks`;
const path = organization ? `organizations/${organization}/settings/apps/new` : 'settings/apps/new'; const organizationPath = organization ? encodeURIComponent(organization.replace(/^\/+|\/+$/g, '')) : '';
const path = organizationPath ? `organizations/${organizationPath}/settings/apps/new` : 'settings/apps/new';
const default_permissions = { const default_permissions = {
contents: 'read', contents: 'read',
metadata: 'read', metadata: 'read',

View file

@ -1,20 +1,22 @@
@can('manageInvitations', currentTeam()) <div>
<form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end"> @can('manageInvitations', currentTeam())
<div class="flex flex-1 lg:w-fit w-full gap-2"> <form wire:submit='viaLink' class="flex gap-2 flex-col lg:flex-row items-end">
<x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required /> <div class="flex flex-1 lg:w-fit w-full gap-2">
<x-forms.select id="role" name="role" label="Role"> <x-forms.input id="email" type="email" label="Email" name="email" placeholder="Email" required />
@if (auth()->user()->role() === 'owner') <x-forms.select id="role" name="role" label="Role">
<option value="owner">Owner</option> @if (auth()->user()->role() === 'owner')
<option value="owner">Owner</option>
@endif
<option value="admin">Admin</option>
<option value="member">Member</option>
</x-forms.select>
</div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif @endif
<option value="admin">Admin</option> </div>
<option value="member">Member</option> </form>
</x-forms.select> @endcan
</div> </div>
<div class="flex gap-2 lg:w-fit w-full">
<x-forms.button type="submit">Generate Invitation Link</x-forms.button>
@if (is_transactional_emails_enabled())
<x-forms.button wire:click.prevent='viaEmail'>Send Invitation via Email</x-forms.button>
@endif
</div>
</form>
@endcan

View file

@ -160,6 +160,7 @@
Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']); Route::post('/databases/{uuid}/backups', [DatabasesController::class, 'create_backup'])->middleware(['api.ability:write']);
Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']); Route::patch('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'update_backup'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}', [DatabasesController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/logs', [DatabasesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}', [DatabasesController::class, 'delete_backup_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']); Route::delete('/databases/{uuid}/backups/{scheduled_backup_uuid}/executions/{execution_uuid}', [DatabasesController::class, 'delete_execution_by_uuid'])->middleware(['api.ability:write']);
@ -195,6 +196,7 @@
Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs/bulk', [ServicesController::class, 'create_bulk_envs'])->middleware(['api.ability:write']);
Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']); Route::patch('/services/{uuid}/envs', [ServicesController::class, 'update_env_by_uuid'])->middleware(['api.ability:write']);
Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']); Route::delete('/services/{uuid}/envs/{env_uuid}', [ServicesController::class, 'delete_env_by_uuid'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/logs', [ServicesController::class, 'logs_by_uuid'])->middleware(['api.ability:read']);
Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/start', [ServicesController::class, 'action_deploy'])->middleware(['api.ability:deploy']);
Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']);

View file

@ -9,7 +9,7 @@
## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24) ## DOCKER_ADDRESS_POOL_SIZE - Custom Docker address pool size (default: 24)
## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false) ## DOCKER_POOL_FORCE_OVERRIDE - Force override Docker address pool configuration (default: false)
## AUTOUPDATE - Set to "false" to disable auto-updates ## AUTOUPDATE - Set to "false" to disable auto-updates
## REGISTRY_URL - Custom registry URL for Docker images (default: ghcr.io) ## REGISTRY_URL - Custom registry URL for Docker images (default: docker.io)
set -e # Exit immediately if a command exits with a non-zero status set -e # Exit immediately if a command exits with a non-zero status
## $1 could be empty, so we need to disable this check ## $1 could be empty, so we need to disable this check
@ -50,7 +50,7 @@ else
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2) REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2)
echo "Using registry URL from .env: $REGISTRY_URL" echo "Using registry URL from .env: $REGISTRY_URL"
else else
REGISTRY_URL="ghcr.io" REGISTRY_URL="docker.io"
echo "Using default registry URL: $REGISTRY_URL" echo "Using default registry URL: $REGISTRY_URL"
fi fi
fi fi
@ -920,9 +920,9 @@ echo -e " - Please wait."
getAJoke getAJoke
if [[ $- == *x* ]]; then if [[ $- == *x* ]]; then
bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" bash -x /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
else else
bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-ghcr.io}" "true" bash /data/coolify/source/upgrade.sh "${LATEST_VERSION:-latest}" "${LATEST_HELPER_VERSION:-latest}" "${REGISTRY_URL:-docker.io}" "true"
fi fi
echo " - Coolify installed successfully." echo " - Coolify installed successfully."
echo " - Waiting for Coolify to be ready..." echo " - Waiting for Coolify to be ready..."

View file

@ -4,9 +4,15 @@
CDN="https://cdn.coollabs.io/coolify" CDN="https://cdn.coollabs.io/coolify"
LATEST_IMAGE=${1:-latest} LATEST_IMAGE=${1:-latest}
LATEST_HELPER_VERSION=${2:-latest} LATEST_HELPER_VERSION=${2:-latest}
REGISTRY_URL=${3:-ghcr.io}
SKIP_BACKUP=${4:-false}
ENV_FILE="/data/coolify/source/.env" ENV_FILE="/data/coolify/source/.env"
if [ -n "${3+x}" ]; then
REGISTRY_URL="$3"
elif [ -f "$ENV_FILE" ] && grep -q "^REGISTRY_URL=" "$ENV_FILE"; then
REGISTRY_URL=$(grep "^REGISTRY_URL=" "$ENV_FILE" | cut -d '=' -f2- | head -n1)
else
REGISTRY_URL="docker.io"
fi
SKIP_BACKUP=${4:-false}
STATUS_FILE="/data/coolify/source/.upgrade-status" STATUS_FILE="/data/coolify/source/.upgrade-status"
DATE=$(date +%Y-%m-%d-%H-%M-%S) DATE=$(date +%Y-%m-%d-%H-%M-%S)
@ -80,7 +86,7 @@ fi
# Get all unique images from docker compose config # Get all unique images from docker compose config
# LATEST_IMAGE env var is needed for image substitution in compose files # LATEST_IMAGE env var is needed for image substitution in compose files
IMAGES=$(LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u) IMAGES=$(REGISTRY_URL=${REGISTRY_URL} LATEST_IMAGE=${LATEST_IMAGE} docker compose --env-file "$ENV_FILE" $COMPOSE_FILES config --images 2>/dev/null | sort -u)
if [ -z "$IMAGES" ]; then if [ -z "$IMAGES" ]; then
log "ERROR: Failed to extract images from docker-compose files" log "ERROR: Failed to extract images from docker-compose files"
@ -127,7 +133,21 @@ update_env_var() {
fi fi
} }
set_env_var() {
local key="$1"
local value="$2"
if grep -q "^${key}=" "$ENV_FILE"; then
sed -i "s|^${key}=.*|${key}=${value}|" "$ENV_FILE"
log "Updated ${key}"
else
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
log "Added ${key}"
fi
}
log "Checking environment variables..." log "Checking environment variables..."
set_env_var "REGISTRY_URL" "$REGISTRY_URL"
update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_ID" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_KEY" "$(openssl rand -hex 32)"
update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)" update_env_var "PUSHER_APP_SECRET" "$(openssl rand -hex 32)"
@ -173,7 +193,7 @@ echo "3/6 Pulling Docker images..."
echo " This may take a few minutes depending on your connection." echo " This may take a few minutes depending on your connection."
# Also pull the helper image (not in compose files but needed for upgrade) # Also pull the helper image (not in compose files but needed for upgrade)
HELPER_IMAGE="${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}" HELPER_IMAGE="${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:${LATEST_HELPER_VERSION}"
echo " - Pulling $HELPER_IMAGE..." echo " - Pulling $HELPER_IMAGE..."
log "Pulling image: $HELPER_IMAGE" log "Pulling image: $HELPER_IMAGE"
if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then if docker pull "$HELPER_IMAGE" >>"$LOGFILE" 2>&1; then
@ -265,7 +285,7 @@ nohup bash -c "
fi fi
log 'Running docker compose up...' log 'Running docker compose up...'
docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1 docker run -v /data/coolify/source:/data/coolify/source -v /var/run/docker.sock:/var/run/docker.sock \${DOCKER_CONFIG_MOUNT} --rm \${REGISTRY_URL:-docker.io}/coollabsio/coolify-helper:\${LATEST_HELPER_VERSION} bash -c \"LATEST_IMAGE=\${LATEST_IMAGE} docker compose --env-file /data/coolify/source/.env \${COMPOSE_FILES} up -d --remove-orphans --wait --wait-timeout 60\" >>\"\$LOGFILE\" 2>&1
log 'Docker compose up completed' log 'Docker compose up completed'
# Final log entry # Final log entry

View file

@ -0,0 +1,81 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'source-commit-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->first();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
});
function sourceCommitSettingApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
describe('PATCH /api/v1/applications/{uuid} include_source_commit_in_build', function () {
test('updates the application setting through the API', function () {
expect((bool) $this->application->settings->include_source_commit_in_build)->toBeFalse();
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => true,
])
->assertOk();
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeTrue();
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => false,
])
->assertOk();
expect((bool) $this->application->fresh()->settings->include_source_commit_in_build)->toBeFalse();
});
test('rejects non boolean values', function () {
$this->withHeaders(sourceCommitSettingApiHeaders($this->bearerToken))
->patchJson("/api/v1/applications/{$this->application->uuid}", [
'include_source_commit_in_build' => 'not-a-boolean',
])
->assertUnprocessable()
->assertJsonValidationErrors('include_source_commit_in_build');
});
});

View file

@ -6,6 +6,7 @@
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -13,10 +14,7 @@
config()->set('app.maintenance.driver', 'file'); config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array'); config()->set('cache.default', 'array');
InstanceSettings::query()->delete(); InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$settings = new InstanceSettings;
$settings->id = 0;
$settings->save();
// Create a team with owner // Create a team with owner
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
@ -29,7 +27,9 @@
$this->bearerToken = $this->token->plainTextToken; $this->bearerToken = $this->token->plainTextToken;
// Create a private key for the team // Create a private key for the team
$this->privateKey = PrivateKey::factory()->create([ $this->privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $this->team->id, 'team_id' => $this->team->id,
]); ]);
}); });
@ -41,6 +41,26 @@ function createGithubAppsApiToken($context, array $abilities): string
return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken; return $context->user->createToken('github-apps-test-token', $abilities)->plainTextToken;
} }
/**
* Generate a temporary 2048-bit RSA private key for GitHub Apps API tests.
*
* Generated in-process so tests do not depend on external files or secrets;
* the key is used only as a signing fixture and is never persisted.
*
* @return string PEM-encoded RSA private key
*/
function validGithubAppsApiPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
]);
openssl_pkey_export($key, $privateKey);
return $privateKey;
}
describe('GET /api/v1/github-apps', function () { describe('GET /api/v1/github-apps', function () {
test('returns 401 when not authenticated', function () { test('returns 401 when not authenticated', function () {
$response = $this->getJson('/api/v1/github-apps'); $response = $this->getJson('/api/v1/github-apps');
@ -195,6 +215,11 @@ function createGithubAppsApiToken($context, array $abilities): string
// Create another team with a GitHub app // Create another team with a GitHub app
$otherTeam = Team::factory()->create(); $otherTeam = Team::factory()->create();
$otherPrivateKey = PrivateKey::create([
'name' => 'Other Key',
'private_key' => validGithubAppsApiPrivateKey(),
'team_id' => $otherTeam->id,
]);
GithubApp::create([ GithubApp::create([
'name' => 'Team 2 App', 'name' => 'Team 2 App',
'api_url' => 'https://api.github.com', 'api_url' => 'https://api.github.com',
@ -204,7 +229,7 @@ function createGithubAppsApiToken($context, array $abilities): string
'client_id' => 'team2-client-id', 'client_id' => 'team2-client-id',
'client_secret' => 'team2-secret', 'client_secret' => 'team2-secret',
'webhook_secret' => 'team2-webhook', 'webhook_secret' => 'team2-webhook',
'private_key_id' => $this->privateKey->id, 'private_key_id' => $otherPrivateKey->id,
'team_id' => $otherTeam->id, 'team_id' => $otherTeam->id,
'is_system_wide' => false, 'is_system_wide' => false,
]); ]);
@ -260,3 +285,179 @@ function createGithubAppsApiToken($context, array $abilities): string
]); ]);
}); });
}); });
describe('GitHub app API url normalization', function () {
test('normalizes ghe dot com api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'html_url' => 'https://github.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
'api_url' => 'https://api.github.ghe.com',
'html_url' => 'https://github.ghe.com',
]);
});
test('preserves provided api url when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => '/octocorp/',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertCreated()
->assertJsonFragment([
'organization' => 'octocorp',
'api_url' => 'https://github.com/api/v3',
'html_url' => 'https://github.com',
]);
});
test('preserves provided api url when updating github apps', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://github.company.internal/api/v3',
'html_url' => 'https://github.company.internal',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'html_url' => 'https://github.com',
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('preserves provided api url when updating api url only', function () {
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/github-apps/{$githubApp->id}", [
'api_url' => 'https://github.com/api/v3',
]);
$response->assertSuccessful()
->assertJsonPath('data.api_url', 'https://github.com/api/v3');
});
test('rejects invalid organization when creating github apps', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/github-apps', [
'name' => 'GHE App',
'organization' => 'octo/corp',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_uuid' => $this->privateKey->uuid,
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['organization']);
});
test('loads repositories and branches through normalized ghe dot com api url', function () {
$this->privateKey->update([
'private_key' => validGithubAppsApiPrivateKey(),
]);
$githubApp = GithubApp::create([
'name' => 'GHE App',
'api_url' => 'https://api.octocorp.ghe.com',
'html_url' => 'https://octocorp.ghe.com',
'app_id' => 12345,
'installation_id' => 67890,
'client_id' => 'test-client-id',
'client_secret' => 'test-client-secret',
'webhook_secret' => 'test-webhook-secret',
'private_key_id' => $this->privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.octocorp.ghe.com/zen' => Http::response('Keep it logically awesome.', 200, [
'Date' => now()->toRfc7231String(),
]),
'https://api.octocorp.ghe.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'installation-token',
]),
'https://api.octocorp.ghe.com/installation/repositories*' => Http::response([
'repositories' => [
['name' => 'repo', 'full_name' => 'octocorp/repo'],
],
]),
'https://api.octocorp.ghe.com/repos/octocorp/repo/branches' => Http::response([
['name' => 'main'],
]),
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories")
->assertSuccessful()
->assertJsonPath('repositories.0.name', 'repo');
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson("/api/v1/github-apps/{$githubApp->id}/repositories/octocorp/repo/branches")
->assertSuccessful()
->assertJsonPath('branches.0.name', 'main');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/installation/repositories?per_page=100&page=1');
Http::assertSent(fn ($request) => $request->url() === 'https://api.octocorp.ghe.com/repos/octocorp/repo/branches');
});
});

Some files were not shown because too many files have changed in this diff Show more