Merge remote-tracking branch 'origin/next' into openid-auth-support

This commit is contained in:
Andras Bacsai 2026-07-14 13:55:35 +02:00
commit bdc7e53fac
379 changed files with 31449 additions and 2953 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)

173
README.md
View file

@ -57,106 +57,95 @@ ## Donations
### Huge Sponsors ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality * [Context.dev](https://www.context.dev/) - Web scraping API for AI agents
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API * [SerpAPI](https://serpapi.com) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API.
* [Seibert Group](https://seibert.link/coolifysoftware?ref=coolify.io) - Boost productivity company-wide with AI agents like Claude Code * [MVPS](https://www.mvps.net) - Cheap VPS servers at the highest possible quality
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs * [ScreenshotOne](https://screenshotone.com) - Screenshot API for devs
* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control * [PrivateAlps](https://privatealps.net) - Cloud Services Provider, VPS, servers infrastructure for people who care about privacy and control
* [Seibert Group](https://seibert.link/coolifysoftware) - Boost productivity company-wide with AI agents like Claude Code
* [Contabo](https://contabo.com/en/coolify-vps/) - Cloud VPS & dedicated servers at unbeatable prices
### Big Sponsors ### Big Sponsors
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions! * [Cloudways](https://www.cloudways.com/en/?id=2125302) - Managed cloud hosting platform by DigitalOcean
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services * [ByteBase](https://www.bytebase.com) - Database CI/CD and Security at Scale
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions * [Ramnode](https://ramnode.com/) - High Performance Cloud VPS Hosting
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner * [23M](https://23m.com) - Your experts for high-availability hosting solutions!
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform * [Macarne](https://macarne.com) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [CompAI](https://www.trycomp.ai?ref=coolify.io) - Open source compliance automation platform
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [Darweb](https://darweb.nl/?ref=coolify.io) - 3D CPQ solutions for ecommerce design
* [Dataforest Cloud](https://cloud.dataforest.net/en?ref=coolify.io) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [Formbricks](https://formbricks.com?ref=coolify.io) - The open source feedback platform
* [GoldenVM](https://billing.goldenvm.com?ref=coolify.io) - Premium virtual machine hosting solutions
* [Greptile](https://www.greptile.com?ref=coolify.io) - The AI Code Reviewer
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions * [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions * [Logto](https://logto.io) - The better identity infrastructure for developers
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers * [Supadata](https://supadata.ai/) - Scrape YouTube, web, and files. Get AI-ready, clean data for your next project.
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency * [Tolgee](https://tolgee.io) - The open source localization platform
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions * [Best Consultant](https://bc.direct) - Your trusted technology consulting partner
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers * [ArcJet](https://arcjet.com) - Advanced web security and performance solutions
* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting * [SupaGuide](https://supa.guide) - Your comprehensive guide to Supabase
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity * [CodeRabbit](https://coderabbit.ai) - Cut Code Review Time & Bugs in Half
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity * [Convex](https://convex.link/coolify.io) - Convex is the open-source reactive database for web app developers.
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions * [GoldenVM](https://billing.goldenvm.com) - Premium virtual machine hosting solutions
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang * [Comit International](https://comit.international) - New York Times awardwinning contractor!
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting * [Compai](https://www.trycomp.ai) - The open source compliance automation platform that does everything you need to get compliant, fast. Open source alternative to Drata & Vanta.
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers * [Tigris](https://www.tigrisdata.com) - Modern S3 Alternative
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase * [Blacksmith](https://blacksmith.sh) - Infrastructure automation platform
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data * [JobsCollider](https://jobscollider.com/remote-jobs) - 30,000+ remote jobs for developers
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers * [Darweb](https://darweb.nl/?ref=coolify.io&utm_source=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions for eCommerce.
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform * [Hostinger](https://www.hostinger.com/vps/coolify-hosting) - Web hosting and VPS solutions
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform * [Mobb](https://vibe.mobb.ai/) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform * [Ubicloud](https://www.ubicloud.com) - Open source cloud infrastructure platform
* [VPSDime](https://vpsdime.com?ref=coolify.io) - Affordable high-performance VPS hosting solutions * [PFGLabs](https://pfglabs.com) - Build Real Projects with Golang
* [JuxtDigital](https://juxtdigital.com) - Digital PR & AI Authority Building Agency
* [SaasyKit](https://saasykit.com) - Complete SaaS starter kit for developers
* [American Cloud](https://americancloud.com) - US-based cloud infrastructure services
* [LiquidWeb](https://liquidweb.com) - Premium managed hosting solutions
* [Greptile](https://www.greptile.com) - The AI Code Reviewer
* [VPSDime](https://vpsdime.com/) - Cheap VPS Hosting - 4GB for $5/month
* [dataforest Cloud](https://cloud.dataforest.net/en) - Deploy cloud servers as seeds independently in seconds. Enterprise hardware, premium network, 100% made in Germany.
* [ISHosting](https://ishosting.com/) - Hosting and VPS solutions
* [PetroSky Cloud](https://petrosky.io) - Open source cloud deployment solutions
* [QuickSrv](https://quicksrv.io/) - Fast and reliable server hosting
### Small Sponsors ### Small Sponsors
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a> <a href="https://www.movavi.com/mac.html?utm_source=coolify.io"><img width="60px" alt="Movavi" src="https://cdn.coollabs.io/sponsors/movavi.png"/></a>
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="XamanApp" src="https://github.com/XamanApp.png"/></a> <a href="https://usefoil.com/"><img width="60px" alt="ABXY" src="https://usefoil.com/favicon.svg"/></a>
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a> <a href="https://www.launchfa.st/?utm_source=coolify.io"><img width="60px" alt="LaunchFast Boilerplates" src="https://github.com/LaunchFast-Boilerplates.png"/></a>
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a> <a href="https://vanaways.co.uk/?utm_source=coolify.io"><img width="60px" alt="Vanaways" src="https://github.com/Vanaways.png"/></a>
<a href="https://github.com/iujlaki"><img width="60px" alt="Imre Ujlaki" src="https://github.com/iujlaki.png"/></a> <a href="https://www.netrouting.com/?utm_source=coolify.io"><img width="60px" alt="Netrouting" src="https://github.com/netroutingcom.png"/></a>
<a href="https://bsky.app/profile/jyc.dev"><img width="60px" alt="jyc.dev" src="https://github.com/jycouet.png"/></a> <a href="https://github.com/mindedtech"><img width="60px" alt="MindEd Tech" src="https://github.com/mindedtech.png"/></a>
<a href="https://github.com/therealjp?utm_source=coolify.io"><img width="60px" alt="TheRealJP" src="https://github.com/therealjp.png"/></a>
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
<a href="https://github.com/aniftyco"><img width="60px" alt="NiftyCo" src="https://github.com/aniftyco.png"/></a>
<a href="https://dry.software/?utm_source=coolify.io"><img width="60px" alt="Dry Software" src="https://github.com/dry-software.png"/></a>
<a href="https://lightspeed.run/?utm_source=coolify.io"><img width="60px" alt="Lightspeed.run" src="https://github.com/lightspeedrun.png"/></a>
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://github.com/LLM-Inc.png"/></a>
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
<a href="https://bitlaunch.io/?utm_source=coolify.io"><img width="60px" alt="BitLaunch" src="https://github.com/bitlaunchio.png"/></a>
<a href="https://bestforandroid.com/?utm_source=coolify.io"><img width="60px" alt="Best for Android" src="https://github.com/bestforandroid.png"/></a>
<a href="https://il.ly/?utm_source=coolify.io"><img width="60px" alt="Ilias Ism" src="https://github.com/Illyism.png"/></a>
<a href="https://formbricks.com/?utm_source=coolify.io"><img width="60px" alt="Formbricks" src="https://github.com/formbricks.png"/></a>
<a href="https://www.serversearcher.com/"><img width="60px" alt="Server Searcher" src="https://github.com/serversearcher.png"/></a>
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://coolify.io/images/reshotai.png"/></a>
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://coolify.io/images/cirun-logo.png"/></a>
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="Typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
<a href="https://cccareers.org/?utm_source=coolify.io"><img width="60px" alt="Creating Coding Careers" src="https://github.com/cccareers.png"/></a>
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://coolify.io/images/internetgarden.ico"/></a>
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://coolify.io/images/web3jobs.png"/></a>
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://coolify.io/images/codext.jpg"/></a>
<a href="https://github.com/monocursive"><img width="60px" alt="Michael Mazurczak" src="https://github.com/monocursive.png"/></a>
<a href="https://fider.io/?utm_source=coolify.io"><img width="60px" alt="Fider" src="https://github.com/getfider.png"/></a>
<a href="https://www.flint.sh/en/home?utm_source=coolify.io"><img width="60px" alt="Flint" src="https://github.com/Flint-company.png"/></a>
<a href="https://github.com/urtho"><img width="60px" alt="Paweł Pierścionek" src="https://github.com/urtho.png"/></a>
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://coolify.io/images/runpod.svg"/></a>
<a href="https://dartnode.com/?utm_source=coolify.io"><img width="60px" alt="DartNode" src="https://github.com/dartnode.png"/></a>
<a href="https://github.com/whitesidest"><img width="60px" alt="Tyler Whitesides" src="https://avatars.githubusercontent.com/u/12365916?s=52&v=4"/></a>
<a href="https://aquarela.io"><img width="60px" alt="Aquarela" src="https://github.com/aquarela-io.png"/></a>
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
<a href="https://www.youtube.com/@AlfredNutile?utm_source=coolify.io"><img width="60px" alt="Alfred Nutile" src="https://github.com/alnutile.png"/></a>
<a href="https://startupfa.me?utm_source=coolify.io"><img width="60px" alt="Startup Fame" src="https://github.com/startupfame.png"/></a>
<a href="https://barrad.me/?utm_source=coolify.io"><img width="60px" alt="Younes Barrad" src="https://github.com/Flowko.png"/></a>
<a href="https://jonasjaeger.com?utm_source=coolify.io"><img width="60px" alt="Jonas Jaeger" src="https://github.com/toxin20.png"/></a>
<a href="https://pixel.ao/?utm_source=coolify.io"><img width="60px" alt="Pixel Infinito" src="https://github.com/pixelinfinito.png"/></a>
<a href="https://github.com/corentinclichy"><img width="60px" alt="Corentin Clichy" src="https://github.com/corentinclichy.png"/></a>
<a href="https://x.com/mrsmith9ja?utm_source=coolify.io"><img width="60px" alt="Thompson Edolo" src="https://github.com/verygreenboi.png"/></a>
<a href="https://devhuset.no?utm_source=coolify.io"><img width="60px" alt="Devhuset" src="https://github.com/devhuset.png"/></a>
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://coolify.io/images/arvensis.png"/></a>
<a href="https://github.com/Niki2k1"><img width="60px" alt="Niklas Lausch" src="https://github.com/Niki2k1.png"/></a>
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
<a href="https://interviewpal.com/?utm_source=coolify.io"><img width="60px" alt="InterviewPal" src="/public/svgs/interviewpal.svg"/></a>
<a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
<a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a> <a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a>
<a href="https://github.com/mindedtech?utm_source=coolify.io"><img width="60px" alt="MindedTech" src="https://github.com/mindedtech.png"/></a> <a href="https://transcript.lol/?utm_source=coolify.io"><img width="60px" alt="Transcript LOL" src="https://transcript.lol/logo.png"/></a>
<a href="https://netrouting.com/?utm_source=coolify.io"><img width="60px" alt="NetRouting" src="https://github.com/netroutingcom.png"/></a> <a href="https://www.autom.dev/?utm_source=coolify.io"><img width="60px" alt="Autom" src="https://cdn.coollabs.io/sponsors/autom.png"/></a>
<a href="https://github.com/parsecph?utm_source=coolify.io"><img width="60px" alt="ParsecPH" src="https://github.com/parsecph.png"/></a> <a href="https://www.huntapi.com/?utm_source=coolify.io"><img width="60px" alt="HuntAPI" src="https://cdn.coollabs.io/sponsors/huntapi.png"/></a>
<a href="https://ultraservers.com/?utm_source=coolify.io"><img width="60px" alt="ULTRASERVERS" src="https://github.com/ULTRASERVERS.png"/></a>
<a href="https://vibetone.com/?utm_source=coolify.io"><img width="60px" alt="VibeTone" src="https://github.com/vibetonefm.png"/></a>
<a href="https://www.piloterr.com/?utm_source=coolify.io"><img width="60px" alt="Piloterr" src="https://cdn.coollabs.io/sponsors/piloterr.svg"/></a>
<a href="https://yoxel.com/?utm_source=coolify.io"><img width="60px" alt="Alexey Panteleev" src="https://github.com/aspantel.png"/></a>
<a href="https://summyt.app?utm_source=coolify.io"><img width="60px" alt="SummYT - YouTube Summarizer" src="https://summyt.app/logo.svg"/></a>
<a href="https://open-elements.com/?utm_source=coolify.io"><img width="60px" alt="OpenElements" src="https://github.com/OpenElements.png"/></a>
<a href="https://xaman.app/?utm_source=coolify.io"><img width="60px" alt="Xaman" src="https://github.com/XamanApp.png"/></a>
<a href="https://monadical.com/?utm_source=coolify.io"><img width="60px" alt="Monadical" src="https://github.com/Monadical-SAS.png"/></a>
<a href="https://maas.engineering/?utm_source=coolify.io"><img width="60px" alt="Magic as a Service" src="https://github.com/magicasaservice.png"/></a>
<a href="https://fivemanage.com?utm_source=coolify.io"><img width="60px" alt="FiveManage" src="https://cdn.coollabs.io/sponsors/fivemanage.jpg"/></a>
<a href="https://cryptojobslist.com/?utm_source=coolify.io"><img width="60px" alt="Crypto Jobs List" src="https://github.com/cryptojobslist.png"/></a>
<a href="https://serpapi.com/?utm_source=coolify.io"><img width="60px" alt="SerpAPI" src="https://github.com/serpapi.png"/></a>
<a href="https://typebot.io/?utm_source=coolify.io"><img width="60px" alt="typebot" src="https://cdn.bsky.app/img/avatar/plain/did:plc:gwxcta3pccyim4z5vuultdqx/bafkreig23hci7e2qpdxicsshnuzujbcbcgmydxhbybkewszdezhdodv42m@jpeg"/></a>
<a href="https://360creators.com/?utm_source=coolify.io"><img width="60px" alt="360Creators" src="https://opencollective-production.s3.us-west-1.amazonaws.com/account-avatar/503e0953-bff7-4296-b4cc-5e36d40eecc0/icon-360creators.png"/></a>
<a href="https://capgo.app/?utm_source=coolify.io"><img width="60px" alt="Cap-go" src="https://github.com/cap-go.png"/></a>
<a href="https://cirun.io/?utm_source=coolify.io"><img width="60px" alt="Cirun" src="https://cdn.coollabs.io/sponsors/cirun-logo.png"/></a>
<a href="https://github.com/puls-digital-group"><img width="60px" alt="Puls Digital Group" src="https://github.com/puls-digital-group.png"/></a>
<a href="https://github.com/jonathanprl"><img width="60px" alt="Jonathan Pereira" src="https://github.com/jonathanprl.png"/></a>
<a href="https://internetgarden.co/?utm_source=coolify.io"><img width="60px" alt="Internet Garden" src="https://cdn.coollabs.io/sponsors/internetgarden.ico"/></a>
<a href="https://evercam.io/?utm_source=coolify.io"><img width="60px" alt="Evercam" src="https://github.com/evercam.png"/></a>
<a href="https://web3.career/?utm_source=coolify.io"><img width="60px" alt="Web3 Jobs" src="https://cdn.coollabs.io/sponsors/web3jobs.png"/></a>
<a href="https://linkdr.com?utm_source=coolify.io"><img width="60px" alt="LinkDr" src="https://cdn.coollabs.io/sponsors/linkdr.svg"/></a>
<a href="https://arvensis.systems/?utm_source=coolify.io"><img width="60px" alt="Arvensis Systems" src="https://cdn.coollabs.io/sponsors/arvensis.png"/></a>
<a href="https://www.reshot.ai/?utm_source=coolify.io"><img width="60px" alt="Reshot" src="https://cdn.coollabs.io/sponsors/reshotai.png"/></a>
<a href="https://www.runpod.io/?utm_source=coolify.io"><img width="60px" alt="RunPod" src="https://cdn.coollabs.io/sponsors/runpod.svg"/></a>
<a href="http://gravitywiz.com/?utm_source=coolify.io"><img width="60px" alt="Gravity Wiz" src="https://github.com/gravitywiz.png"/></a>
<a href="https://www.uxwizz.com/?utm_source=coolify.io"><img width="60px" alt="UXWizz" src="https://github.com/UXWizz.png"/></a>
<a href="https://codext.link/coolify-io?utm_source=coolify.io"><img width="60px" alt="Codext" src="https://cdn.coollabs.io/sponsors/codext.jpg"/></a>
<a href="https://interviewpal.com"><img width="60px" alt="InterviewPal" src="https://cdn.coollabs.io/sponsors/interviewpal.svg"/></a>
<a href="https://decidable.no?utm_source=coolify.io"><img width="60px" alt="Decidable" src="https://github.com/Decidable-AS.png"/></a>
<a href="https://hosthavoc.com"><img width="60px" alt="Host Havoc" src="https://cdn.coollabs.io/sponsors/hosthavoc.png"/></a>
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio) ...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)

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

@ -0,0 +1,16 @@
<?php
namespace App\Actions\Destination;
use App\Models\StandaloneDocker;
class RemoveStandaloneDockerNetwork
{
public function handle(StandaloneDocker $destination): void
{
$safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
}
}

View file

@ -4,7 +4,9 @@
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
@ -12,6 +14,16 @@
class CreateNewUser implements CreatesNewUsers class CreateNewUser implements CreatesNewUsers
{ {
private const REGISTRATION_IP_MAX_ATTEMPTS = 3;
private const REGISTRATION_IP_DECAY_SECONDS = 600;
private const REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS = 3;
private const REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS = 3600;
public function __construct(private readonly Request $request) {}
/** /**
* Validate and create a newly registered user. * Validate and create a newly registered user.
* *
@ -23,6 +35,9 @@ public function create(array $input): User
if (! $settings->isPasswordRegistrationAllowed()) { if (! $settings->isPasswordRegistrationAllowed()) {
abort(403); abort(403);
} }
$this->ensureRegistrationIsNotRateLimited($input);
Validator::make($input, [ Validator::make($input, [
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
'email' => [ 'email' => [
@ -72,4 +87,42 @@ public function create(array $input): User
return $user; return $user;
} }
/**
* @param array<string, string> $input
*/
private function ensureRegistrationIsNotRateLimited(array $input): void
{
$keys = [
[
'key' => 'registration:ip:'.sha1($this->realIp()),
'max' => self::REGISTRATION_IP_MAX_ATTEMPTS,
'decay' => self::REGISTRATION_IP_DECAY_SECONDS,
],
];
$emailIdentity = normalize_email_identity($input['email'] ?? null);
if ($emailIdentity !== null) {
$keys[] = [
'key' => 'registration:email-identity:'.sha1($emailIdentity),
'max' => self::REGISTRATION_EMAIL_IDENTITY_MAX_ATTEMPTS,
'decay' => self::REGISTRATION_EMAIL_IDENTITY_DECAY_SECONDS,
];
}
foreach ($keys as $limit) {
if (RateLimiter::tooManyAttempts($limit['key'], $limit['max'])) {
abort(429, 'Too many registration attempts. Please try again later.');
}
}
foreach ($keys as $limit) {
RateLimiter::hit($limit['key'], $limit['decay']);
}
}
private function realIp(): string
{
return $this->request->server('REMOTE_ADDR') ?? $this->request->ip();
}
} }

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

@ -6,14 +6,16 @@
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed; use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\DigitalOceanService;
use App\Services\HetznerService; use App\Services\HetznerService;
use App\Services\VultrService;
use Lorisleiva\Actions\Concerns\AsAction; use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer class DeleteServer
{ {
use AsAction; use AsAction;
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null) public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null)
{ {
$server = Server::withTrashed()->find($serverId); $server = Server::withTrashed()->find($serverId);
@ -26,22 +28,32 @@ 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 ($deleteFromVultr && ($vultrInstanceId || ($server && $server->vultr_instance_id))) {
$this->deleteFromVultrById(
$vultrInstanceId ?? $server->vultr_instance_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
if ($deleteFromDigitalOcean && ($digitalOceanDropletId || ($server && $server->digitalocean_droplet_id))) {
$this->deleteFromDigitalOceanById(
$digitalOceanDropletId ?? $server->digitalocean_droplet_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
logger()->debug($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,
@ -56,7 +68,10 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$token = null; $token = null;
if ($cloudProviderTokenId) { if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId); $token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
} }
if (! $token) { if (! $token) {
@ -66,10 +81,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 +88,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', [
@ -100,4 +102,84 @@ private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProvider
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage())); $team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
} }
} }
private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'vultr')
->first();
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'vultr')
->first();
}
if (! $token) {
throw new \RuntimeException('No Vultr token found for the server team.');
}
$vultrService = new VultrService($token->token);
$vultrService->deleteInstance($vultrInstanceId);
logger()->debug('Deleted server from Vultr', [
'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
logger()->error('Failed to delete server from Vultr', [
'error' => $e->getMessage(),
'vultr_instance_id' => $vultrInstanceId,
'team_id' => $teamId,
]);
throw $e;
}
}
private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::where('id', $cloudProviderTokenId)
->where('team_id', $teamId)
->where('provider', 'digitalocean')
->first();
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'digitalocean')
->first();
}
if (! $token) {
throw new \RuntimeException('No DigitalOcean token found for the server team.');
}
$digitalOceanService = new DigitalOceanService($token->token);
$digitalOceanService->deleteDroplet($digitalOceanDropletId);
logger()->debug('Deleted droplet from DigitalOcean', [
'digitalocean_droplet_id' => $digitalOceanDropletId,
'team_id' => $teamId,
]);
} catch (\Throwable $e) {
logger()->error('Failed to delete droplet from DigitalOcean', [
'error' => $e->getMessage(),
'digitalocean_droplet_id' => $digitalOceanDropletId,
'team_id' => $teamId,
]);
throw $e;
}
}
} }

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

@ -28,6 +28,32 @@ public function handle(Server $server)
$server->update([ $server->update([
'validation_logs' => null, 'validation_logs' => null,
]); ]);
if ($server->vultr_instance_id) {
$status = $server->refreshVultrState();
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$server->update([
'validation_logs' => $this->error,
]);
throw new \Exception($this->error);
}
}
if ($server->digitalocean_droplet_id) {
$status = $server->refreshDigitalOceanState();
if (in_array($status, ['off', 'archive', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.'
: 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.';
$server->update([
'validation_logs' => $this->error,
]);
throw new \Exception($this->error);
}
}
['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection();
if (! $this->uptime) { if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');

View file

@ -0,0 +1,65 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
use Spatie\Activitylog\Contracts\Activity;
class DeployServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication, bool $pullLatestImages = false, bool $forceRebuild = false): Activity
{
$service = $serviceApplication->service;
$composeServiceName = $serviceApplication->name;
$service->parse();
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
$workdir = $service->workdir();
$composeFile = "{$workdir}/docker-compose.yml";
$safeWorkdir = escapeshellarg($workdir);
$safeComposeFile = escapeshellarg($composeFile);
$safeProjectName = escapeshellarg($service->uuid);
$safeComposeServiceName = escapeshellarg($composeServiceName);
$commands = collect([
'echo '.escapeshellarg("Saved configuration files to {$workdir}."),
'touch '.escapeshellarg("{$workdir}/.env"),
]);
if ($pullLatestImages) {
$commands->push('echo Pulling image for service.');
$commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}");
}
if ($service->networks()->count() > 0) {
$commands->push('echo Creating Docker network.');
$commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}");
}
$upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps";
if ($forceRebuild) {
$upCommand .= ' --build';
}
$upCommand .= " {$safeComposeServiceName}";
$commands->push('echo Starting service container.');
$commands->push($upCommand);
$commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true");
if (data_get($service, 'connect_to_docker_network')) {
$network = escapeshellarg($service->destination->network);
$containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}");
$networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}");
$commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true");
}
return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged');
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class RestartServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
instant_remote_process([
"docker restart {$containerName}",
], $server);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use Lorisleiva\Actions\Concerns\AsAction;
class StopServiceApplication
{
use AsAction;
public string $jobQueue = 'high';
public function handle(ServiceApplication $serviceApplication): void
{
$service = $serviceApplication->service;
$server = $service->destination->server;
$containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid);
instant_remote_process([
"docker stop {$containerName}",
], $server);
}
}

View file

@ -0,0 +1,107 @@
<?php
namespace App\Actions\Service;
use App\Models\ServiceApplication;
use App\Support\ServiceComposeUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class UpdateServiceApplicationFromApi
{
public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse
{
$forceDomainOverride = $request->boolean('force_domain_override');
if (array_key_exists('url', $payload)) {
$urlRaw = $payload['url'];
if ($urlRaw !== null && ! is_string($urlRaw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['url' => 'The url must be a string.'],
], 422);
}
$parsed = ServiceComposeUrl::validateUrlString(
is_string($urlRaw) ? $urlRaw : null,
$forceDomainOverride
);
if (count($parsed['errors']) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $parsed['errors'],
], 422);
}
if ($parsed['normalized'] !== null) {
$containerUrls = str($parsed['normalized'])
->explode(',')
->map(fn ($url) => str(trim((string) $url))->lower());
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid);
if (isset($result['error'])) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [$result['error']],
], 422);
}
if ($result['hasConflicts'] && ! $forceDomainOverride) {
return response()->json([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.',
'conflicts' => $result['conflicts'],
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.',
], 409);
}
}
$serviceApplication->fqdn = $parsed['normalized'];
}
if (array_key_exists('human_name', $payload)) {
$serviceApplication->human_name = $payload['human_name'];
}
if (array_key_exists('description', $payload)) {
$serviceApplication->description = $payload['description'];
}
if (array_key_exists('image', $payload)) {
$serviceApplication->image = $payload['image'];
}
if (array_key_exists('exclude_from_status', $payload)) {
$serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_gzip_enabled', $payload)) {
$serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_stripprefix_enabled', $payload)) {
$serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (array_key_exists('is_log_drain_enabled', $payload)) {
$enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN);
$server = $serviceApplication->service->destination->server;
if ($enabled && ! $server->isLogDrainEnabled()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'],
],
], 422);
}
$serviceApplication->is_log_drain_enabled = $enabled;
}
$serviceApplication->save();
$serviceApplication->refresh();
updateCompose($serviceApplication);
return null;
}
}

View file

@ -5,9 +5,12 @@
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Pool; use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use function Laravel\Prompts\confirm; use function Laravel\Prompts\confirm;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\select;
class SyncBunny extends Command class SyncBunny extends Command
{ {
@ -16,7 +19,7 @@ class SyncBunny extends Command
* *
* @var string * @var string
*/ */
protected $signature = 'sync:bunny {--templates} {--release} {--nightly}'; protected $signature = 'sync:bunny {--bunny}';
/** /**
* The console command description. * The console command description.
@ -25,15 +28,234 @@ class SyncBunny extends Command
*/ */
protected $description = 'Sync files to BunnyCDN'; protected $description = 'Sync files to BunnyCDN';
protected function removeTemporaryDirectory(string $tmpDir): void
{
$temporaryRoot = realpath(sys_get_temp_dir());
$temporaryDirectory = realpath($tmpDir);
if ($temporaryRoot === false || $temporaryDirectory === false) {
return;
}
$expectedPrefix = rtrim($temporaryRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'coollabs-cdn-';
if (! str_starts_with($temporaryDirectory, $expectedPrefix)) {
return;
}
File::deleteDirectory($temporaryDirectory);
}
/**
* Fetch GitHub releases and sync to GitHub repository
*/
private function syncReleasesToGitHubRepo(array $files, bool $nightly = false): bool
{
$this->info('Fetching releases from GitHub...');
try {
$response = Http::timeout(30)
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
'per_page' => 30, // Fetch more releases for better changelog
]);
if (! $response->successful()) {
$this->error('Failed to fetch releases from GitHub: '.$response->status());
return false;
}
$releasesFile = tempnam(sys_get_temp_dir(), 'coolify-releases-');
if ($releasesFile === false || file_put_contents($releasesFile, json_encode($response->json(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
$this->error('Failed to create temporary releases.json.');
return false;
}
$files[$releasesFile] = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
try {
return $this->syncFilesToGitHubRepo($files, $nightly);
} finally {
@unlink($releasesFile);
}
} catch (\Throwable $e) {
$this->error('Error syncing releases: '.$e->getMessage());
return false;
}
}
/**
* Sync install.sh, docker-compose, and env files to GitHub repository via PR
*/
private function syncFilesToGitHubRepo(array $files, bool $nightly = false): bool
{
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("Syncing $envLabel files to GitHub repository...");
try {
$timestamp = time();
$tmpDir = sys_get_temp_dir().'/coollabs-cdn-files-'.$timestamp;
$branchName = 'update-files-'.$timestamp;
// Clone the repository
$this->info('Cloning coollabs-cdn repository...');
$output = [];
exec('gh repo clone coollabsio/coollabs-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to clone repository: '.implode("\n", $output));
return false;
}
// Create feature branch
$this->info('Creating feature branch...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to create branch: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Copy each file to its target path in the CDN repo
$copiedFiles = [];
foreach ($files as $sourceFile => $targetPath) {
if (! file_exists($sourceFile)) {
$this->warn("Source file not found, skipping: $sourceFile");
continue;
}
$destPath = "$tmpDir/$targetPath";
$destDir = dirname($destPath);
if (! is_dir($destDir)) {
if (! mkdir($destDir, 0755, true)) {
$this->error("Failed to create directory: $destDir");
$this->removeTemporaryDirectory($tmpDir);
return false;
}
}
if (copy($sourceFile, $destPath) === false) {
$this->error("Failed to copy $sourceFile to $destPath");
$this->removeTemporaryDirectory($tmpDir);
return false;
}
$copiedFiles[] = $targetPath;
$this->info("Copied: $targetPath");
}
if (empty($copiedFiles)) {
$this->warn('No files were copied. Nothing to commit.');
$this->removeTemporaryDirectory($tmpDir);
return true;
}
// Stage all copied files
$this->info('Staging changes...');
$output = [];
$stageCmd = 'cd '.escapeshellarg($tmpDir).' && git add '.implode(' ', array_map('escapeshellarg', $copiedFiles)).' 2>&1';
exec($stageCmd, $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to stage changes: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Check for changes
$this->info('Checking for changes...');
$changedFiles = [];
exec('cd '.escapeshellarg($tmpDir).' && git diff --cached --name-only 2>&1', $changedFiles, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to check changed files: '.implode("\n", $changedFiles));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
$changedFiles = array_values(array_filter($changedFiles));
if (empty($changedFiles)) {
$this->info('All files are already up to date. No changes to commit.');
$this->removeTemporaryDirectory($tmpDir);
return true;
}
// Commit changes
$commitMessage = "Update $envLabel files (install.sh, docker-compose, env) - ".date('Y-m-d H:i:s');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to commit changes: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Push to remote
$this->info('Pushing branch to remote...');
$output = [];
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
if ($returnCode !== 0) {
$this->error('Failed to push branch: '.implode("\n", $output));
$this->removeTemporaryDirectory($tmpDir);
return false;
}
// Create pull request
$this->info('Creating pull request...');
$prTitle = "Update $envLabel files - ".date('Y-m-d H:i:s');
$fileList = implode("\n- ", $changedFiles);
$prBody = "Automated update of $envLabel files:\n- $fileList";
$prCommand = 'gh pr create --repo coollabsio/coollabs-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
$output = [];
exec($prCommand, $output, $returnCode);
// Clean up
$this->removeTemporaryDirectory($tmpDir);
if ($returnCode !== 0) {
$this->error('Failed to create PR: '.implode("\n", $output));
return false;
}
$this->info('Pull request created successfully!');
if (! empty($output)) {
$this->info('PR URL: '.implode("\n", $output));
}
$this->info('Files synced: '.count($changedFiles));
return true;
} catch (\Throwable $e) {
$this->error('Error syncing files to GitHub: '.$e->getMessage());
return false;
}
}
/** /**
* Execute the console command. * Execute the console command.
*/ */
public function handle() public function handle()
{ {
$that = $this; $that = $this;
$only_template = $this->option('templates'); $only_bunny = $this->option('bunny');
$only_version = $this->option('release'); $nightly = select(
$nightly = $this->option('nightly'); label: 'Which environment would you like to sync?',
options: [
'production' => 'Production',
'nightly' => 'Nightly',
],
default: 'production',
) === 'nightly';
$bunny_cdn = 'https://cdn.coollabs.io'; $bunny_cdn = 'https://cdn.coollabs.io';
$bunny_cdn_path = 'coolify'; $bunny_cdn_path = 'coolify';
$bunny_cdn_storage_name = 'coolcdn'; $bunny_cdn_storage_name = 'coolcdn';
@ -55,6 +277,7 @@ public function handle()
$upgrade_script_location = "$parent_dir/scripts/upgrade.sh"; $upgrade_script_location = "$parent_dir/scripts/upgrade.sh";
$upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh"; $upgrade_postgres_script_location = "$parent_dir/scripts/upgrade-postgres.sh";
$production_env_location = "$parent_dir/.env.production"; $production_env_location = "$parent_dir/.env.production";
$service_template_location = "$parent_dir/templates/$service_template";
$versions_location = "$parent_dir/$versions"; $versions_location = "$parent_dir/$versions";
PendingRequest::macro('storage', function ($fileName) use ($that) { PendingRequest::macro('storage', function ($fileName) use ($that) {
@ -93,7 +316,7 @@ public function handle()
$install_script_location = "$parent_dir/other/nightly/$install_script"; $install_script_location = "$parent_dir/other/nightly/$install_script";
$versions_location = "$parent_dir/other/nightly/$versions"; $versions_location = "$parent_dir/other/nightly/$versions";
} }
if (! $only_template && ! $only_version) { if ($only_bunny) {
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION'; $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$this->info("About to sync $envLabel files to BunnyCDN."); $this->info("About to sync $envLabel files to BunnyCDN.");
$this->newLine(); $this->newLine();
@ -108,7 +331,7 @@ public function handle()
$install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script", $install_script_location => "$bunny_cdn/$bunny_cdn_path/$install_script",
]; ];
$diffTmpDir = sys_get_temp_dir().'/coolify-cdn-diff-'.time(); $diffTmpDir = sys_get_temp_dir().'/coollabs-cdn-diff-'.time();
@mkdir($diffTmpDir, 0755, true); @mkdir($diffTmpDir, 0755, true);
$hasChanges = false; $hasChanges = false;
@ -151,7 +374,7 @@ public function handle()
} }
} }
exec('rm -rf '.escapeshellarg($diffTmpDir)); $this->removeTemporaryDirectory($diffTmpDir);
if (! $hasChanges) { if (! $hasChanges) {
$this->newLine(); $this->newLine();
@ -167,49 +390,55 @@ public function handle()
return; return;
} }
} }
if ($only_template) { if (! $only_bunny) {
$this->info('About to sync '.config('constants.services.file_name').' to BunnyCDN.'); $envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
$confirmed = confirm('Are you sure you want to sync?'); $this->info("About to sync $envLabel releases, versions, compose, and environment files to GitHub repository.");
if (! $confirmed) {
return;
}
Http::pool(fn (Pool $pool) => [
$pool->storage(fileName: "$parent_dir/templates/$service_template")->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$service_template"),
$pool->purge("$bunny_cdn/$bunny_cdn_path/$service_template"),
]);
$this->info('Service template uploaded & purged...');
return;
} elseif ($only_version) {
if ($nightly) { if ($nightly) {
$this->info('About to sync NIGHTLY versions.json to BunnyCDN.'); $files = [
$versions_location => 'json/coolify/nightly/versions.json',
$compose_file_location => 'json/coolify/nightly/docker-compose.yml',
$compose_file_prod_location => 'json/coolify/nightly/docker-compose.prod.yml',
$production_env_location => 'json/coolify/nightly/.env.production',
$install_script_location => 'json/coolify/nightly/install.sh',
$upgrade_script_location => 'json/coolify/nightly/upgrade.sh',
$upgrade_postgres_script_location => 'json/coolify/nightly/upgrade-postgres.sh',
$service_template_location => 'json/coolify/nightly/service-templates-latest.json',
];
} else { } else {
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.'); $files = [
} $versions_location => 'json/coolify/versions.json',
$file = file_get_contents($versions_location); $compose_file_location => 'json/coolify/docker-compose.yml',
$json = json_decode($file, true); $compose_file_prod_location => 'json/coolify/docker-compose.prod.yml',
$actual_version = data_get($json, 'coolify.v4.version'); $production_env_location => 'json/coolify/.env.production',
$install_script_location => 'json/coolify/install.sh',
$this->info("Version: {$actual_version}"); $upgrade_script_location => 'json/coolify/upgrade.sh',
$this->info('This will:'); $upgrade_postgres_script_location => 'json/coolify/upgrade-postgres.sh',
$this->info(' 1. Sync versions.json to BunnyCDN'); $service_template_location => 'json/coolify/service-templates-latest.json',
$this->newLine(); ];
$confirmed = confirm('Are you sure you want to proceed?');
if (! $confirmed) {
return;
} }
$this->info('Syncing versions.json to BunnyCDN...'); $releasesTarget = $nightly ? 'json/coolify/nightly/releases.json' : 'json/coolify/releases.json';
Http::pool(fn (Pool $pool) => [ $options = [$releasesTarget, ...array_values($files)];
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"), $selectedFiles = multiselect(
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"), label: 'Which files would you like to sync?',
]); options: $options,
$this->info('✓ versions.json uploaded & purged to BunnyCDN'); default: $options,
$this->newLine(); required: true,
scroll: count($options),
);
$this->info('=== Summary ==='); $includeReleases = in_array($releasesTarget, $selectedFiles, true);
$this->info('BunnyCDN sync: ✓ Complete'); $files = array_filter(
$files,
fn (string $targetPath) => in_array($targetPath, $selectedFiles, true),
);
if ($includeReleases) {
$this->syncReleasesToGitHubRepo($files, $nightly);
} else {
$this->syncFilesToGitHubRepo($files, $nightly);
}
return; return;
} }
@ -231,10 +460,6 @@ public function handle()
$pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"), $pool->purge("$bunny_cdn/$bunny_cdn_path/$install_script"),
]); ]);
$this->info('All files uploaded & purged to BunnyCDN.'); $this->info('All files uploaded & purged to BunnyCDN.');
$this->newLine();
$this->info('=== Summary ===');
$this->info('BunnyCDN sync: Complete');
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->error('Error: '.$e->getMessage()); $this->error('Error: '.$e->getMessage());
} }

File diff suppressed because it is too large Load diff

View file

@ -16,9 +16,14 @@ private function removeSensitiveData($token)
{ {
$token->makeHidden([ $token->makeHidden([
'id', 'id',
'token',
]); ]);
if (request()->attributes->get('can_read_sensitive', false) === true) {
$token->makeVisible([
'token',
]);
}
return serializeApiResponse($token); return serializeApiResponse($token);
} }
@ -37,6 +42,9 @@ private function validateProviderToken(string $provider, string $token): array
'digitalocean' => Http::withHeaders([ 'digitalocean' => Http::withHeaders([
'Authorization' => 'Bearer '.$token, 'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.digitalocean.com/v2/account'), ])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
'vultr' => Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account'),
default => null, default => null,
}; };
@ -82,7 +90,7 @@ private function validateProviderToken(string $provider, string $token): array
properties: [ properties: [
'uuid' => ['type' => 'string'], 'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'], 'name' => ['type' => 'string'],
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']], 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr']],
'team_id' => ['type' => 'integer'], 'team_id' => ['type' => 'integer'],
'servers_count' => ['type' => 'integer'], 'servers_count' => ['type' => 'integer'],
'created_at' => ['type' => 'string'], 'created_at' => ['type' => 'string'],
@ -200,7 +208,7 @@ public function show(Request $request)
type: 'object', type: 'object',
required: ['provider', 'token', 'name'], required: ['provider', 'token', 'name'],
properties: [ properties: [
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'], 'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean', 'vultr'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'], 'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'], 'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
], ],
@ -255,7 +263,7 @@ public function store(Request $request)
$body = $request->json()->all(); $body = $request->json()->all();
$validator = customApiValidator($body, [ $validator = customApiValidator($body, [
'provider' => 'required|string|in:hetzner,digitalocean', 'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string', 'token' => 'required|string',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
]); ]);

View file

@ -0,0 +1,174 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Http\Controllers\Api\TagsController;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
trait HandlesTagsApi
{
/**
* Find the taggable resource by UUID within the team.
*/
abstract protected function findTaggableResource(string $uuid, int|string $teamId): mixed;
/**
* Get the 404 message for the taggable resource.
*/
abstract protected function tagResourceNotFoundMessage(): string;
public function listTags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('view', $resource);
return response()->json($resource->tags->map(TagsController::serializeTag(...)));
}
public function createTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
if ($request->has('tag_name') && $request->has('tag_names')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Provide either tag_name or tag_names, not both.']],
], 422);
}
$validator = Validator::make($request->all(), [
'tag_name' => 'required_without:tag_names|string',
'tag_names' => 'required_without:tag_name|array|min:1',
'tag_names.*' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), ['tag_name', 'tag_names']);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$tagNames = $this->normalizeTagNames($request->has('tag_names') ? $request->tag_names : [$request->tag_name]);
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tag_name' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$this->attachTagsToResource($resource, $tagNames, $teamId);
return response()->json($resource->refresh()->tags->map(TagsController::serializeTag(...)))->setStatusCode(201);
}
public function deleteTag(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$resource = $this->findTaggableResource($request->route('uuid'), $teamId);
if (! $resource) {
return response()->json(['message' => $this->tagResourceNotFoundMessage()], 404);
}
$this->authorize('update', $resource);
$tag = Tag::where('team_id', $teamId)->where('uuid', $request->route('tag_uuid'))->first();
if (! $tag) {
return response()->json(['message' => 'Tag not found.'], 404);
}
if (! $resource->tags()->whereKey($tag->id)->exists()) {
return response()->json(['message' => 'Tag not found on resource.'], 404);
}
$resource->tags()->detach($tag->id);
$tag->deleteIfOrphaned();
return response()->json(['message' => 'Tag removed.']);
}
protected function attachTagsToResource($resource, array $tagNames, int|string $teamId): void
{
foreach ($this->normalizeTagNames($tagNames) as $tagName) {
if (mb_strlen($tagName) < 2) {
continue;
}
$tag = Tag::query()->createOrFirst([
'team_id' => $teamId,
'name' => $tagName,
]);
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
protected function validateTagsParameter(Request $request): ?JsonResponse
{
if (! $request->has('tags')) {
return null;
}
$tagNames = $this->normalizeTagNames($request->input('tags', []));
$invalidTags = array_filter($tagNames, fn (string $tagName): bool => mb_strlen($tagName) < 2);
if (! empty($invalidTags)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['tags' => ['Each tag name must be at least 2 characters after sanitization.']],
], 422);
}
$request->merge(['tags' => $tagNames]);
return null;
}
protected function normalizeTagNames(array $tagNames): array
{
return collect($tagNames)
->map(fn ($tagName): string => strtolower(trim(strip_tags((string) $tagName))))
->unique()
->values()
->all();
}
}

View file

@ -20,6 +20,7 @@
use App\Models\Server; use App\Models\Server;
use App\Models\StandalonePostgresql; use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -27,28 +28,123 @@
class DatabasesController extends Controller class DatabasesController extends Controller
{ {
private function removeSensitiveData($database) use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
}
protected function tagResourceNotFoundMessage(): string
{
return 'Database not found.';
}
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
$storage->makeVisible(['content']);
}
return $storage;
}
private function removeSensitiveData($database, bool $loadNestedServerSecrets = false)
{ {
$database->makeHidden([ $database->makeHidden([
'id', 'id',
'laravel_through_key', 'laravel_through_key',
]); ]);
if (request()->attributes->get('can_read_sensitive', false) === false) { if (request()->attributes->get('can_read_sensitive', false) === true) {
$database->makeHidden([ $database->makeVisible([
'internal_db_url', 'internal_db_url',
'external_db_url', 'external_db_url',
'init_scripts',
'postgres_password', 'postgres_password',
'dragonfly_password', 'dragonfly_password',
'redis_password', 'redis_password',
'mongo_initdb_root_password', 'mongo_initdb_root_password',
'keydb_password', 'keydb_password',
'clickhouse_admin_password', 'clickhouse_admin_password',
'mysql_password',
'mysql_root_password',
'mariadb_password',
'mariadb_root_password',
]); ]);
$this->exposeNestedServerSecrets($database);
} else {
$this->hideNestedServerSecrets($database, $loadNestedServerSecrets);
} }
return serializeApiResponse($database); return serializeApiResponse($database);
} }
private function hideNestedServerSecrets(Model $model, bool $loadRelations = false): void
{
if ($loadRelations) {
$server = data_get($model, 'destination.server');
} else {
if (! $model->relationLoaded('destination')) {
return;
}
$destination = $model->getRelation('destination');
if (! $destination || ! $destination->relationLoaded('server')) {
return;
}
$server = $destination->getRelation('server');
}
if (! $server) {
return;
}
$server->makeHidden([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
if ($loadRelations || $server->relationLoaded('settings')) {
$server->settings->makeHidden([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
*/
private function exposeNestedServerSecrets(Model $model): void
{
$server = $model->destination?->server;
if ($server === null) {
return;
}
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
if ($server->settings !== null) {
$server->settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
#[OA\Get( #[OA\Get(
summary: 'List', summary: 'List',
description: 'List all databases.', description: 'List all databases.',
@ -85,8 +181,12 @@ public function databases(Request $request)
} }
$projects = Project::where('team_id', $teamId)->get(); $projects = Project::where('team_id', $teamId)->get();
$databases = collect(); $databases = collect();
$databaseRelations = $request->attributes->get('can_read_sensitive', false) === true
? ['destination.server.settings']
: [];
foreach ($projects as $project) { foreach ($projects as $project) {
$databases = $databases->merge($project->databases()); $databases = $databases->merge($project->databases($databaseRelations));
} }
$databaseIds = $databases->pluck('id')->toArray(); $databaseIds = $databases->pluck('id')->toArray();
@ -228,7 +328,7 @@ public function database_by_uuid(Request $request)
$this->authorize('view', $database); $this->authorize('view', $database);
return response()->json($this->removeSensitiveData($database)); return response()->json($this->removeSensitiveData($database, loadNestedServerSecrets: true));
} }
#[OA\Patch( #[OA\Patch(
@ -1132,6 +1232,7 @@ public function update_backup(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1200,6 +1301,7 @@ public function create_database_postgresql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1267,6 +1369,7 @@ public function create_database_clickhouse(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1335,6 +1438,7 @@ public function create_database_dragonfly(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1403,6 +1507,7 @@ public function create_database_redis(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1474,6 +1579,7 @@ public function create_database_keydb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1545,6 +1651,7 @@ public function create_database_mariadb(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1613,6 +1720,7 @@ public function create_database_mysql(Request $request)
'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'], 'limits_cpuset' => ['type' => 'string', 'description' => 'CPU set of the database'],
'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'], 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares of the database'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'], 'instant_deploy' => ['type' => 'boolean', 'description' => 'Instant deploy the database'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the database.'],
], ],
), ),
) )
@ -1643,7 +1751,7 @@ public function create_database_mongodb(Request $request)
public function create_database(Request $request, NewDatabaseTypes $type) public function create_database(Request $request, NewDatabaseTypes $type)
{ {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'clickhouse_admin_user', 'clickhouse_admin_password', 'dragonfly_password', 'redis_password', 'redis_conf', 'keydb_password', 'keydb_conf', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -1742,6 +1850,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'limits_cpuset' => 'string|nullable', 'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric', 'limits_cpu_shares' => 'numeric',
'instant_deploy' => 'boolean', 'instant_deploy' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]); ]);
if ($validator->failed()) { if ($validator->failed()) {
return response()->json([ return response()->json([
@ -1749,6 +1859,13 @@ public function create_database(Request $request, NewDatabaseTypes $type)
'errors' => $validator->errors(), 'errors' => $validator->errors(),
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
$tagNames = $request->input('tags') ?? [];
if ($request->public_port) { if ($request->public_port) {
if ($request->public_port < 1024 || $request->public_port > 65535) { if ($request->public_port < 1024 || $request->public_port > 65535) {
return response()->json([ return response()->json([
@ -1760,7 +1877,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} }
} }
if ($type === NewDatabaseTypes::POSTGRESQL) { if ($type === NewDatabaseTypes::POSTGRESQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_password' => ValidationPatterns::databasePasswordRules(required: false), 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -1808,6 +1925,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
'uuid' => $database->uuid, 'uuid' => $database->uuid,
@ -1829,7 +1949,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) { } elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string', 'mariadb_conf' => 'string',
'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -1876,6 +1996,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -1898,7 +2021,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) { } elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false), 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -1945,6 +2068,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -1967,7 +2093,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) { } elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'redis_password' => ValidationPatterns::databasePasswordRules(required: false), 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string', 'redis_conf' => 'string',
@ -2011,6 +2137,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2033,7 +2162,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) { } elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false), 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]); ]);
@ -2058,12 +2187,15 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
return response()->json(serializeApiResponse([ return response()->json(serializeApiResponse([
'uuid' => $database->uuid, 'uuid' => $database->uuid,
]))->setStatusCode(201); ]))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::KEYDB) { } elseif ($type === NewDatabaseTypes::KEYDB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'keydb_password' => ValidationPatterns::databasePasswordRules(required: false), 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string', 'keydb_conf' => 'string',
@ -2107,6 +2239,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2129,7 +2264,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) { } elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false), 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false), 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
@ -2153,6 +2288,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2175,7 +2313,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
return response()->json(serializeApiResponse($payload))->setStatusCode(201); return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) { } elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database']; $allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database', 'tags'];
$validator = customApiValidator($request->all(), [ $validator = customApiValidator($request->all(), [
'mongo_conf' => 'string', 'mongo_conf' => 'string',
'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false), 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
@ -2221,6 +2359,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($instantDeploy) { if ($instantDeploy) {
StartDatabase::dispatch($database); StartDatabase::dispatch($database);
} }
if ($tagNames !== []) {
$this->attachTagsToResource($database, $tagNames, $teamId);
}
$database->refresh(); $database->refresh();
$payload = [ $payload = [
@ -2247,6 +2388,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.',
@ -2692,6 +2943,99 @@ public function list_backup_executions(Request $request)
]); ]);
} }
#[OA\Post(
summary: 'Move',
description: 'Move database to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the database will pick up shared environment variables from the new environment on the next deployment.',
path: '/databases/{uuid}/move',
operationId: 'move-database-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',
)
),
],
requestBody: new OA\RequestBody(
description: 'Target environment to move the database to.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
],
required: ['environment_uuid'],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Database moved successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Database moved successfully.'],
'uuid' => ['type' => 'string'],
'project_uuid' => ['type' => 'string'],
'environment_uuid' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('update', $database);
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
}
#[OA\Get( #[OA\Get(
summary: 'Start', summary: 'Start',
description: 'Start database. `Post` request is also accepted.', description: 'Start database. `Post` request is also accepted.',
@ -2970,8 +3314,8 @@ private function removeSensitiveEnvData($env)
'resourceable_id', 'resourceable_id',
'resourceable_type', 'resourceable_type',
]); ]);
if (request()->attributes->get('can_read_sensitive', false) === false) { if (request()->attributes->get('can_read_sensitive', false) === true) {
$env->makeHidden([ $env->makeVisible([
'value', 'value',
'real_value', 'real_value',
]); ]);
@ -3611,6 +3955,7 @@ public function storages(Request $request): JsonResponse
$persistentStorages = $database->persistentStorages->sortBy('id')->values(); $persistentStorages = $database->persistentStorages->sortBy('id')->values();
$fileStorages = $database->fileStorages->sortBy('id')->values(); $fileStorages = $database->fileStorages->sortBy('id')->values();
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
return response()->json([ return response()->json([
'persistent_storages' => $persistentStorages, 'persistent_storages' => $persistentStorages,
@ -3849,7 +4194,7 @@ public function create_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path, 'mount_path' => $storage->mount_path,
]); ]);
return response()->json($storage, 201); return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
} }
#[OA\Patch( #[OA\Patch(
@ -4056,7 +4401,7 @@ public function update_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path ?? null, 'mount_path' => $storage->mount_path ?? null,
]); ]);
return response()->json($storage); return response()->json($this->exposeFileStorageContentIfAllowed($storage));
} }
#[OA\Delete( #[OA\Delete(
@ -4143,4 +4488,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']); return response()->json(['message' => 'Storage deleted.']);
} }
#[OA\Get(
summary: 'List Tags',
description: 'List tags for a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'list-tags-by-database-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')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
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 tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to a database by UUID.',
path: '/databases/{uuid}/tags',
operationId: 'create-tag-by-database-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')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from a database by UUID.',
path: '/databases/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-database-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')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
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 delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
} }

View file

@ -24,6 +24,10 @@ private function removeSensitiveData($deployment)
$deployment->makeHidden([ $deployment->makeHidden([
'logs', 'logs',
]); ]);
} else {
$deployment->makeVisible([
'logs',
]);
} }
return serializeApiResponse($deployment); return serializeApiResponse($deployment);
@ -365,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;
@ -425,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]);
} }
@ -471,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);
} }
@ -529,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,
]); ]);
@ -698,6 +702,9 @@ public function get_application_deployments(Request $request)
$this->authorize('view', $application); $this->authorize('view', $application);
$deployments = $application->deployments($skip, $take); $deployments = $application->deployments($skip, $take);
if ($request->attributes->get('can_read_sensitive', false) === true) {
$deployments['deployments']->each->makeVisible(['logs']);
}
return response()->json($deployments); return response()->json($deployments);
} }

View file

@ -0,0 +1,356 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class DestinationsController extends Controller
{
private function transform(StandaloneDocker|SwarmDocker $destination): array
{
return [
'uuid' => $destination->uuid,
'name' => $destination->name,
'network' => $destination->network,
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $destination->server?->uuid,
'created_at' => $destination->created_at,
'updated_at' => $destination->updated_at,
];
}
/**
* Resolve the calling token's team id, or return an invalid-token response.
*/
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return $teamId;
}
/**
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
* controller works on Coolify versions that pre-date that scope being added
* to the destination models (e.g. 4.0.0-beta.470).
*/
private function teamScopedDockers(int $teamId): array
{
return [
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
];
}
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
{
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
#[OA\Get(
summary: 'List destinations',
description: 'List all Docker network destinations for the authenticated team.',
path: '/destinations',
operationId: 'list-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
responses: [
new OA\Response(
response: 200,
description: 'Destinations for the authenticated team.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
],
)]
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$sets = $this->teamScopedDockers($teamId);
return response()->json(
$sets['standalone']->concat($sets['swarm'])
->map(fn ($destination) => $this->transform($destination))
->values()
);
}
#[OA\Get(
summary: 'List destinations by server',
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'list-server-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destinations attached to the server.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
->whereTeamId($teamId)
->whereUuid($server_uuid)
->firstOrFail();
$list = $server->standaloneDockers->concat($server->swarmDockers);
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
#[OA\Get(
summary: 'Get destination',
description: 'Get a Docker network destination by UUID.',
path: '/destinations/{uuid}',
operationId: 'get-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination details.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
return response()->json($this->transform($destination));
}
#[OA\Post(
summary: 'Create destination',
description: 'Create a Docker network destination on a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'create-server-destination',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['network'],
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 255),
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
],
type: 'object',
),
),
responses: [
new OA\Response(
response: 201,
description: 'Destination created.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$allowed = ['name', 'network', 'type'];
$validator = customApiValidator($request->all(), [
'name' => 'nullable|string|max:255',
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
'type' => 'nullable|in:standalone,swarm',
]);
$extra = array_diff(array_keys($request->all()), $allowed);
if ($validator->fails() || ! empty($extra)) {
$errors = $validator->errors();
if (! empty($extra)) {
foreach ($extra as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
$type = $request->input('type', $expectedType);
if ($type !== $expectedType) {
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
}
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
$this->authorize('create', $class);
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
if ($exists) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
try {
$destination = $class::create([
'name' => $name,
'network' => $request->input('network'),
'server_id' => $server->id,
]);
} catch (QueryException $exception) {
if ($this->isUniqueConstraintViolation($exception)) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
throw $exception;
}
auditLog('api.destination.created', [
'team_id' => $teamId,
'destination_uuid' => $destination->uuid,
'destination_name' => $destination->name,
'destination_type' => $type,
'server_uuid' => $server->uuid,
]);
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
}
private function isUniqueConstraintViolation(QueryException $exception): bool
{
$sqlState = $exception->errorInfo[0] ?? null;
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
return in_array($sqlState, ['23000', '23505'], true)
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
#[OA\Delete(
summary: 'Delete destination',
description: 'Delete an unused Docker network destination.',
path: '/destinations/{uuid}',
operationId: 'delete-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination deleted.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
],
type: 'object',
),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Destination has attached resources.'),
],
)]
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
$this->authorize('delete', $destination);
// Guard against deleting destinations with attached resources. attachedTo()
// is recent on the destination models; fall back to a manual check for
// older Coolify versions (e.g. 4.0.0-beta.470).
if (method_exists($destination, 'attachedTo')) {
if ($destination->attachedTo()) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
} else {
$hasAttached = $destination->applications()->exists()
|| $destination->postgresqls()->exists()
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|| (method_exists($destination, 'services') && $destination->services()->exists());
if ($hasAttached) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
}
if ($destination instanceof StandaloneDocker) {
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
}
$destinationUuid = $destination->uuid;
$destinationName = $destination->name;
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
$serverUuid = $destination->server?->uuid;
$destination->delete();
auditLog('api.destination.deleted', [
'team_id' => $teamId,
'destination_uuid' => $destinationUuid,
'destination_name' => $destinationName,
'destination_type' => $destinationType,
'server_uuid' => $serverUuid,
]);
return response()->json(['message' => 'Deleted.']);
}
}

View file

@ -0,0 +1,416 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\DigitalOceanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenApi\Attributes as OA;
class DigitalOceanController extends Controller
{
private function getCloudProviderTokenUuid(Request $request): ?string
{
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
}
private function digitalOceanToken(Request $request, int $teamId): CloudProviderToken|JsonResponse
{
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'digitalocean')
->first();
if (! $token) {
return response()->json(['message' => 'DigitalOcean cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return $token;
}
#[OA\Get(
path: '/digitalocean/regions',
operationId: 'get-digitalocean-regions',
summary: 'Get DigitalOcean regions',
security: [['bearerAuth' => []]],
tags: ['DigitalOcean'],
parameters: [
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'List of DigitalOcean regions.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
]
)]
public function regions(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new DigitalOceanService($token->token))->getRegions());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch DigitalOcean regions.'], 500);
}
}
#[OA\Get(
path: '/digitalocean/sizes',
operationId: 'get-digitalocean-sizes',
summary: 'Get DigitalOcean sizes',
security: [['bearerAuth' => []]],
tags: ['DigitalOcean'],
parameters: [
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'List of DigitalOcean sizes.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
]
)]
public function sizes(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new DigitalOceanService($token->token))->getSizes());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch DigitalOcean sizes.'], 500);
}
}
#[OA\Get(
path: '/digitalocean/images',
operationId: 'get-digitalocean-images',
summary: 'Get DigitalOcean images',
security: [['bearerAuth' => []]],
tags: ['DigitalOcean'],
parameters: [
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'List of DigitalOcean images.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
]
)]
public function images(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new DigitalOceanService($token->token))->getImages());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch DigitalOcean images.'], 500);
}
}
#[OA\Get(
path: '/digitalocean/ssh-keys',
operationId: 'get-digitalocean-ssh-keys',
summary: 'Get DigitalOcean SSH keys',
security: [['bearerAuth' => []]],
tags: ['DigitalOcean'],
parameters: [
new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(response: 200, description: 'List of DigitalOcean SSH keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
]
)]
public function sshKeys(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new DigitalOceanService($token->token))->getSshKeys());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch DigitalOcean SSH keys.'], 500);
}
}
#[OA\Post(
path: '/servers/digitalocean',
operationId: 'create-digitalocean-server',
summary: 'Create a server on DigitalOcean',
security: [['bearerAuth' => []]],
tags: ['DigitalOcean'],
responses: [
new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'),
]
)]
public function createServer(Request $request): JsonResponse
{
$allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id',
'region',
'size',
'image',
'name',
'private_key_uuid',
'enable_ipv6',
'monitoring',
'digitalocean_ssh_key_ids',
'cloud_init_script',
'instant_validate',
];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [Server::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
'region' => 'required|string',
'size' => 'required|string',
'image' => 'required',
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
'private_key_uuid' => 'required|string',
'enable_ipv6' => 'nullable|boolean',
'monitoring' => 'nullable|boolean',
'digitalocean_ssh_key_ids' => 'nullable|array',
'digitalocean_ssh_key_ids.*' => 'integer',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
$request->offsetSet('name', $request->name ?: generate_random_name());
$request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true));
$request->offsetSet('monitoring', $request->boolean('monitoring', true));
$request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []);
$request->offsetSet('instant_validate', $request->boolean('instant_validate', false));
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
return $token;
}
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
$digitalOceanService = null;
$dropletId = null;
$server = null;
try {
$digitalOceanService = new DigitalOceanService($token->token);
$sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey);
$sshKeys = array_values(array_unique(array_merge(
[$sshKeyId],
$request->digitalocean_ssh_key_ids
)));
$normalizedServerName = strtolower(trim($request->name));
$params = [
'name' => $normalizedServerName,
'region' => $request->region,
'size' => $request->size,
'image' => $request->image,
'ssh_keys' => $sshKeys,
'ipv6' => $request->enable_ipv6,
'monitoring' => $request->monitoring,
];
if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script;
}
$droplet = $digitalOceanService->createDroplet($params);
$dropletId = (int) $droplet['id'];
$server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server {
$server = Server::create([
'name' => $normalizedServerName,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
report($e);
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
auditLog('api.digitalocean_droplet.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'digitalocean_droplet_id' => $dropletId,
'ip' => $server->ip,
]);
return response()->json([
'uuid' => $server->uuid,
'digitalocean_droplet_id' => $dropletId,
'ip' => $server->ip,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
logger()->error('Failed to create DigitalOcean server', [
'error' => $e->getMessage(),
]);
return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500);
}
}
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
{
if (! $digitalOceanService || ! $dropletId || $server) {
return;
}
try {
$digitalOceanService->deleteDroplet($dropletId);
} catch (\Throwable $e) {
report($e);
}
}
private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int
{
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
foreach ($digitalOceanService->getSshKeys() as $key) {
if (($key['fingerprint'] ?? null) === $md5Fingerprint) {
return (int) $key['id'];
}
}
$uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey());
return (int) $uploadedKey['id'];
}
}

View file

@ -17,10 +17,17 @@ class GithubController extends Controller
{ {
private function removeSensitiveData($githubApp) private function removeSensitiveData($githubApp)
{ {
$githubApp->makeHidden([ if (request()->attributes->get('can_read_sensitive', false) === true) {
'client_secret', $githubApp->makeVisible([
'webhook_secret', 'client_secret',
]); 'webhook_secret',
]);
} else {
$githubApp->makeHidden([
'client_secret',
'webhook_secret',
]);
}
return serializeApiResponse($githubApp); return serializeApiResponse($githubApp);
} }
@ -129,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'],
), ),
), ),
], ],
@ -205,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',
@ -252,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),
@ -589,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];
@ -639,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

@ -460,6 +460,195 @@ public function sshKeys(Request $request)
} }
} }
#[OA\Get(
summary: 'Get Hetzner Firewalls',
description: 'Get all existing Hetzner firewalls for the current project.',
path: '/hetzner/firewalls',
operationId: 'get-hetzner-firewalls',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner firewalls.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function firewalls(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getFirewalls());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner Networks',
description: 'Get all existing Hetzner private networks for the current project.',
path: '/hetzner/networks',
operationId: 'get-hetzner-networks',
security: [
['bearerAuth' => []],
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
required: false,
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_id',
in: 'query',
required: false,
deprecated: true,
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of Hetzner networks.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'name' => ['type' => 'string'],
'ip_range' => ['type' => 'string'],
]
)
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function networks(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($tokenUuid)
->where('provider', 'hetzner')
->first();
if (! $token) {
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
try {
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getNetworks());
} catch (\Throwable $e) {
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
}
}
#[OA\Post( #[OA\Post(
summary: 'Create Hetzner Server', summary: 'Create Hetzner Server',
description: 'Create a new server on Hetzner and register it in Coolify.', description: 'Create a new server on Hetzner and register it in Coolify.',
@ -487,7 +676,10 @@ public function sshKeys(Request $request)
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'],
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'], 'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
], ],
@ -545,7 +737,10 @@ public function createServer(Request $request)
'private_key_uuid', 'private_key_uuid',
'enable_ipv4', 'enable_ipv4',
'enable_ipv6', 'enable_ipv6',
'enable_backups',
'hetzner_ssh_key_ids', 'hetzner_ssh_key_ids',
'hetzner_firewall_ids',
'hetzner_network_ids',
'cloud_init_script', 'cloud_init_script',
'instant_validate', 'instant_validate',
]; ];
@ -571,8 +766,13 @@ public function createServer(Request $request)
'private_key_uuid' => 'required|string', 'private_key_uuid' => 'required|string',
'enable_ipv4' => 'nullable|boolean', 'enable_ipv4' => 'nullable|boolean',
'enable_ipv6' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean',
'enable_backups' => 'nullable|boolean',
'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids' => 'nullable|array',
'hetzner_ssh_key_ids.*' => 'integer', 'hetzner_ssh_key_ids.*' => 'integer',
'hetzner_firewall_ids' => 'nullable|array',
'hetzner_firewall_ids.*' => 'integer',
'hetzner_network_ids' => 'nullable|array',
'hetzner_network_ids.*' => 'integer',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean', 'instant_validate' => 'nullable|boolean',
]); ]);
@ -608,13 +808,32 @@ public function createServer(Request $request)
if (is_null($request->enable_ipv6)) { if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true); $request->offsetSet('enable_ipv6', true);
} }
if (is_null($request->enable_backups)) {
$request->offsetSet('enable_backups', false);
}
if (is_null($request->hetzner_ssh_key_ids)) { if (is_null($request->hetzner_ssh_key_ids)) {
$request->offsetSet('hetzner_ssh_key_ids', []); $request->offsetSet('hetzner_ssh_key_ids', []);
} }
if (is_null($request->hetzner_firewall_ids)) {
$request->offsetSet('hetzner_firewall_ids', []);
}
if (is_null($request->hetzner_network_ids)) {
$request->offsetSet('hetzner_network_ids', []);
}
if (is_null($request->instant_validate)) { if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false); $request->offsetSet('instant_validate', false);
} }
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'enable_ipv4' => ['Enable at least one public IP protocol.'],
'enable_ipv6' => ['Enable at least one public IP protocol.'],
],
], 422);
}
// Validate cloud provider token // Validate cloud provider token
$tokenUuid = $this->getCloudProviderTokenUuid($request); $tokenUuid = $this->getCloudProviderTokenUuid($request);
$token = CloudProviderToken::whereTeamId($teamId) $token = CloudProviderToken::whereTeamId($teamId)
@ -687,6 +906,18 @@ public function createServer(Request $request)
], ],
]; ];
$firewallIds = array_values(array_unique($request->hetzner_firewall_ids));
if ($firewallIds !== []) {
$params['firewalls'] = array_map(function (int $firewallId): array {
return ['firewall' => $firewallId];
}, $firewallIds);
}
$networkIds = array_values(array_unique($request->hetzner_network_ids));
if ($networkIds !== []) {
$params['networks'] = $networkIds;
}
// Add cloud-init script if provided // Add cloud-init script if provided
if (! empty($request->cloud_init_script)) { if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script; $params['user_data'] = $request->cloud_init_script;
@ -723,6 +954,14 @@ public function createServer(Request $request)
$server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save(); $server->save();
if ($request->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
// Validate server if requested // Validate server if requested
if ($request->instant_validate) { if ($request->instant_validate) {
ValidateServer::dispatch($server); ValidateServer::dispatch($server);

View file

@ -166,6 +166,9 @@ public function environment_details(Request $request)
return response()->json(['message' => 'Environment not found.'], 404); return response()->json(['message' => 'Environment not found.'], 404);
} }
$environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']); $environment = $environment->load(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services']);
collect(['applications', 'postgresqls', 'redis', 'mongodbs', 'mysqls', 'mariadbs', 'services'])
->flatMap(fn (string $relation) => $environment->{$relation})
->each(fn ($resource) => exposeSensitiveFields($resource));
return response()->json(serializeApiResponse($environment)); return response()->json(serializeApiResponse($environment));
} }

View file

@ -56,6 +56,7 @@ public function resources(Request $request)
} }
$resources = $resources->flatten(); $resources = $resources->flatten();
$resources = $resources->map(function ($resource) { $resources = $resources->map(function ($resource) {
exposeSensitiveFields($resource);
$payload = $resource->toArray(); $payload = $resource->toArray();
$payload['status'] = $resource->status; $payload['status'] = $resource->status;
$payload['type'] = $resource->type(); $payload['type'] = $resource->type();

View file

@ -16,6 +16,10 @@ private function removeSensitiveData($team)
$team->makeHidden([ $team->makeHidden([
'private_key', 'private_key',
]); ]);
} else {
$team->makeVisible([
'private_key',
]);
} }
return serializeApiResponse($team); return serializeApiResponse($team);

View file

@ -97,11 +97,6 @@ public function push(Request $request)
if ($this->shouldDispatchUpdate($server, $data)) { if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data); PushServerUpdateJob::dispatch($server, $data);
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
} }
return response()->json(['message' => 'ok'], 200); return response()->json(['message' => 'ok'], 200);
@ -148,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
* health checks can flap between starting/healthy/unhealthy while the * health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat * container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often. * the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name * The snapshot completeness flag is included so a complete snapshot always
* so container ordering from Sentinel does not affect the hash. * dispatches after a partial snapshot. Sorted by name so container ordering
* from Sentinel does not affect the hash.
*/ */
private function containerStateHash(array $data): string private function containerStateHash(array $data): string
{ {
@ -162,6 +158,14 @@ private function containerStateHash(array $data): string
->values() ->values()
->all(); ->all();
return hash('xxh128', json_encode($containers)); return hash('xxh128', json_encode([
'snapshot_complete' => $this->isCompleteSnapshot($data),
'containers' => $containers,
]));
}
private function isCompleteSnapshot(array $data): bool
{
return data_get($data, 'snapshot.complete', true) !== false;
} }
} }

View file

@ -23,9 +23,14 @@ class ServersController extends Controller
{ {
private function removeSensitiveDataFromSettings($settings) private function removeSensitiveDataFromSettings($settings)
{ {
if (request()->attributes->get('can_read_sensitive', false) === false) { if (request()->attributes->get('can_read_sensitive', false) === true) {
$settings = $settings->makeHidden([ $settings = $settings->makeVisible([
'sentinel_token', 'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]); ]);
} }
@ -37,8 +42,11 @@ private function removeSensitiveData($server)
$server->makeHidden([ $server->makeHidden([
'id', 'id',
]); ]);
if (request()->attributes->get('can_read_sensitive', false) === false) { if (request()->attributes->get('can_read_sensitive', false) === true) {
// Do nothing $server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
} }
return serializeApiResponse($server); return serializeApiResponse($server);
@ -854,7 +862,11 @@ public function delete_server(Request $request)
false, // Don't delete from Hetzner via API false, // Don't delete from Hetzner via API
$server->hetzner_server_id, $server->hetzner_server_id,
$server->cloud_provider_token_id, $server->cloud_provider_token_id,
$server->team_id $server->team_id,
false, // Don't delete from Vultr via API
$server->vultr_instance_id,
false, // Don't delete from DigitalOcean via API
$server->digitalocean_droplet_id
); );
auditLog('api.server.deleted', [ auditLog('api.server.deleted', [

View file

@ -0,0 +1,766 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\RestartServiceApplication;
use App\Actions\Service\StopServiceApplication;
use App\Actions\Service\UpdateServiceApplicationFromApi;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceApplication;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
class ServiceApplicationsController extends Controller
{
private function removeSensitiveData(ServiceApplication $serviceApplication): array
{
$serviceApplication->makeHidden([
'id',
'resourceable',
'resourceable_id',
'resourceable_type',
]);
$serialized = serializeApiResponse($serviceApplication);
if ($serialized instanceof Collection) {
return $serialized->all();
}
return (array) $serialized;
}
private function resolveService(Request $request, int $teamId): ?Service
{
$uuid = $request->route('uuid');
if (! $uuid) {
return null;
}
return Service::whereRelation('environment.project.team', 'id', $teamId)
->whereUuid($uuid)
->first();
}
private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication
{
$appUuid = $request->route('app_uuid');
if (! $appUuid) {
return null;
}
return $service->applications()
->where('uuid', $appUuid)
->with(['service.destination.server'])
->first();
}
private function swarmNotSupportedResponse(): JsonResponse
{
return response()->json([
'message' => 'This operation is not supported for Swarm servers yet.',
], 501);
}
#[OA\Get(
summary: 'List service applications',
description: 'List compose service applications (containers) for a single service.',
path: '/services/{uuid}/applications',
operationId: 'list-service-applications-by-service-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service applications for this service.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(type: 'object')
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function index(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$this->authorize('view', $service);
$items = $service->applications()
->get()
->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa));
return response()->json($items);
}
#[OA\Get(
summary: 'Get service application',
description: 'Get a single compose service application by service UUID and application UUID.',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'get-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function show(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Patch(
summary: 'Update service application',
description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).',
path: '/services/{uuid}/applications/{app_uuid}',
operationId: 'patch-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force_domain_override',
in: 'query',
description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
requestBody: new OA\RequestBody(
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'url' => new OA\Property(
property: 'url',
type: 'string',
nullable: true,
description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.'
),
'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true),
'description' => new OA\Property(property: 'description', type: 'string', nullable: true),
'image' => new OA\Property(property: 'image', type: 'string', nullable: true),
'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true),
'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true),
'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true),
'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true),
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'Updated service application.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(type: 'object')
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 409,
description: 'Domain conflicts (unless force_domain_override).',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('update', $serviceApplication);
$payload = $request->json()->all();
if (empty($payload)) {
$payload = $request->request->all();
}
$allowedFields = [
'url',
'human_name',
'description',
'image',
'exclude_from_status',
'is_log_drain_enabled',
'is_gzip_enabled',
'is_stripprefix_enabled',
];
$validationRules = [
'url' => 'nullable|string',
'human_name' => 'nullable|string|max:255',
'description' => 'nullable|string',
'image' => 'nullable|string',
'exclude_from_status' => 'sometimes|boolean',
'is_log_drain_enabled' => 'sometimes|boolean',
'is_gzip_enabled' => 'sometimes|boolean',
'is_stripprefix_enabled' => 'sometimes|boolean',
];
$validator = Validator::make($payload, $validationRules);
$extraFields = array_diff(array_keys($payload), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload);
if ($response instanceof JsonResponse) {
return $response;
}
$serviceApplication->refresh();
return response()->json($this->removeSensitiveData($serviceApplication));
}
#[OA\Get(
summary: 'Get service application logs',
description: 'Get Docker logs for a single compose service container.',
path: '/services/{uuid}/applications/{app_uuid}/logs',
operationId: 'get-service-application-logs-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
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)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Logs.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'logs' => new OA\Property(property: 'logs', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function logs_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('view', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
$status = getContainerStatus($server, $containerName);
if ($status !== 'running') {
return response()->json([
'message' => 'Service application container is not running.',
], 400);
}
$lines = (int) ($request->query('lines', 100) ?: 100);
$logs = getContainerLogs($server, $containerName, $lines);
return response()->json([
'logs' => $logs,
]);
}
#[OA\Get(
summary: 'Start or redeploy service application container',
description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.',
path: '/services/{uuid}/applications/{app_uuid}/start',
operationId: 'start-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'force',
in: 'query',
description: 'When true, passes --build to docker compose up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
new OA\Parameter(
name: 'latest',
in: 'query',
description: 'When true, pulls the image for this compose service before up.',
required: false,
schema: new OA\Schema(type: 'boolean', default: false)
),
],
responses: [
new OA\Response(
response: 200,
description: 'Deploy request queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_start(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
$pullLatest = $request->boolean('latest', false);
$forceRebuild = $request->boolean('force', false);
DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild);
return response()->json([
'message' => 'Service application deploy request queued.',
], 200);
}
#[OA\Get(
summary: 'Restart service application container',
description: 'Restarts a single compose service container (docker restart).',
path: '/services/{uuid}/applications/{app_uuid}/restart',
operationId: 'restart-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Restart queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_restart(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
RestartServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application restart request queued.',
], 200);
}
#[OA\Get(
summary: 'Stop service application container',
description: 'Stops a single compose service container (docker stop).',
path: '/services/{uuid}/applications/{app_uuid}/stop',
operationId: 'stop-service-application-by-service-and-app-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Service applications'],
parameters: [
new OA\Parameter(
name: 'uuid',
in: 'path',
description: 'Service UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'app_uuid',
in: 'path',
description: 'Service application UUID.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Stop queued.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => new OA\Property(property: 'message', type: 'string'),
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 501,
description: 'Swarm not supported.',
),
]
)]
public function action_stop(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$service = $this->resolveService($request, $teamId);
if (! $service) {
return response()->json(['message' => 'Service not found.'], 404);
}
$serviceApplication = $this->resolveServiceApplicationForService($request, $service);
if (! $serviceApplication) {
return response()->json(['message' => 'Service application not found.'], 404);
}
$this->authorize('deploy', $serviceApplication);
$server = $serviceApplication->service->destination->server;
if ($server->isSwarm()) {
return $this->swarmNotSupportedResponse();
}
if (! $server->isFunctional()) {
return response()->json([
'message' => 'Server is not functional.',
], 400);
}
StopServiceApplication::dispatch($serviceApplication);
return response()->json([
'message' => 'Service application stop request queued.',
], 200);
}
}

View file

@ -14,29 +14,57 @@
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
class ServicesController extends Controller class ServicesController extends Controller
{ {
use Concerns\HandlesTagsApi;
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
{
return Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($uuid)->first();
}
protected function tagResourceNotFoundMessage(): string
{
return 'Service not found.';
}
private function exposeFileStorageContentIfAllowed(LocalFileVolume|LocalPersistentVolume $storage): LocalFileVolume|LocalPersistentVolume
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
$storage->makeVisible(['content']);
}
return $storage;
}
private function removeSensitiveData($service) private function removeSensitiveData($service)
{ {
if ($service instanceof Collection) {
return $service->map(fn (Service $item) => $this->removeSensitiveData($item));
}
$service->makeHidden([ $service->makeHidden([
'id', 'id',
'resourceable', 'resourceable',
'resourceable_id', 'resourceable_id',
'resourceable_type', 'resourceable_type',
]); ]);
if (request()->attributes->get('can_read_sensitive', false) === false) { if (request()->attributes->get('can_read_sensitive', false) === true) {
$service->makeHidden([ $service->makeVisible([
'docker_compose_raw', 'docker_compose_raw',
'docker_compose', 'docker_compose',
'value', 'value',
'real_value', 'real_value',
]); ]);
$this->exposeNestedServerSecrets($service);
} }
if ($service->is_shown_once ?? false) { if ($service->is_shown_once ?? false) {
@ -46,6 +74,42 @@ private function removeSensitiveData($service)
return serializeApiResponse($service); return serializeApiResponse($service);
} }
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
* Handles both single models and Eloquent Collections (the listing endpoint
* passes a Collection of Services per project to removeSensitiveData()).
*/
private function exposeNestedServerSecrets(Model|Collection $model): void
{
if ($model instanceof Collection) {
foreach ($model as $item) {
$this->exposeNestedServerSecrets($item);
}
return;
}
$server = $model->destination?->server ?? $model->server ?? null;
if (! $server) {
return;
}
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
$settings = $server->settings ?? null;
if ($settings) {
$settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array
{ {
$errors = []; $errors = [];
@ -60,19 +124,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) {
@ -101,10 +156,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'];
@ -116,8 +171,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;
} }
@ -181,8 +234,12 @@ public function services(Request $request)
} }
$projects = Project::where('team_id', $teamId)->get(); $projects = Project::where('team_id', $teamId)->get();
$services = collect(); $services = collect();
$serviceRelations = $request->attributes->get('can_read_sensitive', false) === true
? ['destination.server.settings']
: [];
foreach ($projects as $project) { foreach ($projects as $project) {
$services->push($project->services()->get()); $services->push($project->services()->with($serviceRelations)->get());
} }
foreach ($services as $service) { foreach ($services as $service) {
$service = $this->removeSensitiveData($service); $service = $this->removeSensitiveData($service);
@ -231,6 +288,7 @@ public function services(Request $request)
], ],
'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'], 'force_domain_override' => ['type' => 'boolean', 'default' => false, 'description' => 'Force domain override even if conflicts are detected.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. 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. If you want to use env variables inside the labels, turn this off.'],
'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the service.'],
], ],
), ),
), ),
@ -297,7 +355,7 @@ public function services(Request $request)
)] )]
public function create_service(Request $request) public function create_service(Request $request)
{ {
$allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $allowedFields = ['type', 'name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$teamId = getTeamIdFromToken(); $teamId = getTeamIdFromToken();
if (is_null($teamId)) { if (is_null($teamId)) {
@ -327,6 +385,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -348,6 +408,11 @@ public function create_service(Request $request)
], 422); ], 422);
} }
$return = $this->validateTagsParameter($request);
if ($return instanceof JsonResponse) {
return $return;
}
if (filled($request->type) && filled($request->docker_compose_raw)) { if (filled($request->type) && filled($request->docker_compose_raw)) {
return response()->json([ return response()->json([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.', 'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.',
@ -486,6 +551,10 @@ public function create_service(Request $request)
} }
} }
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }
@ -506,7 +575,7 @@ public function create_service(Request $request)
return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404); return response()->json(['message' => 'Service not found.', 'valid_service_types' => $serviceKeys], 404);
} elseif (filled($request->docker_compose_raw)) { } elseif (filled($request->docker_compose_raw)) {
$allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled']; $allowedFields = ['name', 'description', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'docker_compose_raw', 'connect_to_docker_network', 'urls', 'force_domain_override', 'is_container_label_escape_enabled', 'tags'];
$validationRules = [ $validationRules = [
'project_uuid' => 'string|required', 'project_uuid' => 'string|required',
@ -525,6 +594,8 @@ public function create_service(Request $request)
'urls.*.url' => 'string|nullable', 'urls.*.url' => 'string|nullable',
'force_domain_override' => 'boolean', 'force_domain_override' => 'boolean',
'is_container_label_escape_enabled' => 'boolean', 'is_container_label_escape_enabled' => 'boolean',
'tags' => 'array|nullable',
'tags.*' => 'string|min:2',
]; ];
$validationMessages = [ $validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.', 'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.',
@ -658,6 +729,10 @@ public function create_service(Request $request)
} }
} }
if ($request->has('tags')) {
$this->attachTagsToResource($service, $request->tags, $teamId);
}
if ($instantDeploy) { if ($instantDeploy) {
StartService::dispatch($service); StartService::dispatch($service);
} }
@ -737,11 +812,135 @@ public function service_by_uuid(Request $request)
$this->authorize('view', $service); $this->authorize('view', $service);
$service = $service->load(['applications', 'databases']); $serviceRelations = ['applications', 'databases'];
if ($request->attributes->get('can_read_sensitive', false) === true) {
$serviceRelations[] = 'destination.server.settings';
}
$service = $service->load($serviceRelations);
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.',
@ -1670,6 +1869,99 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable deleted.']); return response()->json(['message' => 'Environment variable deleted.']);
} }
#[OA\Post(
summary: 'Move',
description: 'Move service to another project/environment. This is a purely organizational change — running containers are not affected. Note: after moving, the service will pick up shared environment variables from the new environment on the next deployment.',
path: '/services/{uuid}/move',
operationId: 'move-service-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',
)
),
],
requestBody: new OA\RequestBody(
description: 'Target environment to move the service to.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'environment_uuid' => ['type' => 'string', 'description' => 'UUID of the target environment.'],
],
required: ['environment_uuid'],
)
),
]
),
responses: [
new OA\Response(
response: 200,
description: 'Service moved successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Service moved successfully.'],
'uuid' => ['type' => 'string'],
'project_uuid' => ['type' => 'string'],
'environment_uuid' => ['type' => 'string'],
]
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function move_by_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID 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);
}
$this->authorize('update', $service);
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
}
#[OA\Get( #[OA\Get(
summary: 'Start', summary: 'Start',
description: 'Start service. `Post` request is also accepted.', description: 'Start service. `Post` request is also accepted.',
@ -2029,6 +2321,8 @@ public function storages(Request $request): JsonResponse
); );
} }
$fileStorages->each(fn (LocalFileVolume $storage) => $this->exposeFileStorageContentIfAllowed($storage));
return response()->json([ return response()->json([
'persistent_storages' => $persistentStorages->sortBy('id')->values(), 'persistent_storages' => $persistentStorages->sortBy('id')->values(),
'file_storages' => $fileStorages->sortBy('id')->values(), 'file_storages' => $fileStorages->sortBy('id')->values(),
@ -2276,7 +2570,7 @@ public function create_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path, 'mount_path' => $storage->mount_path,
]); ]);
return response()->json($storage, 201); return response()->json($this->exposeFileStorageContentIfAllowed($storage), 201);
} }
#[OA\Patch( #[OA\Patch(
@ -2513,7 +2807,7 @@ public function update_storage(Request $request): JsonResponse
'mount_path' => $storage->mount_path ?? null, 'mount_path' => $storage->mount_path ?? null,
]); ]);
return response()->json($storage); return response()->json($this->exposeFileStorageContentIfAllowed($storage));
} }
#[OA\Delete( #[OA\Delete(
@ -2627,4 +2921,148 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']); return response()->json(['message' => 'Storage deleted.']);
} }
#[OA\Get(
summary: 'List Tags',
description: 'List tags for a service by UUID.',
path: '/services/{uuid}/tags',
operationId: 'list-tags-by-service-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')
),
],
responses: [
new OA\Response(
response: 200,
description: 'List of tags.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
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 tags(Request $request): JsonResponse
{
return $this->listTags($request);
}
#[OA\Post(
summary: 'Create Tag',
description: 'Add tag(s) to a service by UUID.',
path: '/services/{uuid}/tags',
operationId: 'create-tag-by-service-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')
),
],
requestBody: new OA\RequestBody(
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'tag_name' => ['type' => 'string', 'description' => 'The tag name (min 2 characters). Required if tag_names is not provided.'],
'tag_names' => [
'type' => 'array',
'items' => new OA\Items(type: 'string'),
'description' => 'Array of tag names (each min 2 characters). Required if tag_name is not provided.',
],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Tags added successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function create_tag(Request $request): JsonResponse
{
return $this->createTag($request);
}
#[OA\Delete(
summary: 'Delete Tag',
description: 'Remove a tag from a service by UUID.',
path: '/services/{uuid}/tags/{tag_uuid}',
operationId: 'delete-tag-by-service-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')
),
new OA\Parameter(
name: 'tag_uuid',
in: 'path',
description: 'UUID of the tag.',
required: true,
schema: new OA\Schema(type: 'string')
),
],
responses: [
new OA\Response(
response: 200,
description: 'Tag removed.',
),
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 delete_tag(Request $request): JsonResponse
{
return $this->deleteTag($request);
}
} }

View file

@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class TagsController extends Controller
{
public static function serializeTag(Tag $tag): array
{
return [
'uuid' => $tag->uuid,
'name' => $tag->name,
'created_at' => $tag->created_at,
'updated_at' => $tag->updated_at,
];
}
#[OA\Get(
summary: 'List',
description: 'List all tags for the current team.',
path: '/tags',
operationId: 'list-tags',
security: [
['bearerAuth' => []],
],
tags: ['Tags'],
responses: [
new OA\Response(
response: 200,
description: 'All tags for the current team.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Tag')
)
),
]
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
]
)]
public function tags(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$tags = Tag::where('team_id', $teamId)->orderBy('name')->get();
return response()->json($tags->map(self::serializeTag(...)));
}
}

View file

@ -0,0 +1,406 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\VultrService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class VultrController extends Controller
{
private function getCloudProviderTokenUuid(Request $request): ?string
{
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
}
private function getVultrToken(Request $request): CloudProviderToken|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'vultr')
->first();
if (! $token) {
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
return $token;
}
#[OA\Get(
summary: 'Get Vultr Regions',
description: 'Get all available Vultr regions.',
path: '/vultr/regions',
operationId: 'get-vultr-regions',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr regions.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function regions(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getRegions());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr regions.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr Plans',
description: 'Get all available Vultr plans.',
path: '/vultr/plans',
operationId: 'get-vultr-plans',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr plans.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function plans(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getPlans());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr plans.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr Operating Systems',
description: 'Get all available Vultr operating systems.',
path: '/vultr/os',
operationId: 'get-vultr-operating-systems',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr operating systems.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function operatingSystems(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getOperatingSystems());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr operating systems.'], 500);
}
}
#[OA\Get(
summary: 'Get Vultr SSH Keys',
description: 'Get all Vultr SSH keys available to the selected token.',
path: '/vultr/ssh-keys',
operationId: 'get-vultr-ssh-keys',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 200, description: 'List of Vultr SSH keys.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
]
)]
public function sshKeys(Request $request): JsonResponse
{
$token = $this->getVultrToken($request);
if ($token instanceof JsonResponse) {
return $token;
}
try {
return response()->json((new VultrService($token->token))->getSshKeys());
} catch (\Throwable) {
return response()->json(['message' => 'Failed to fetch Vultr SSH keys.'], 500);
}
}
#[OA\Post(
summary: 'Create Vultr Server',
description: 'Create a Vultr instance and link it as a Coolify server.',
path: '/servers/vultr',
operationId: 'create-vultr-server',
security: [
['bearerAuth' => []],
],
tags: ['Vultr'],
responses: [
new OA\Response(response: 201, description: 'Vultr server created.'),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
]
)]
public function createServer(Request $request): JsonResponse
{
$allowedFields = [
'cloud_provider_token_uuid',
'cloud_provider_token_id',
'region',
'plan',
'os_id',
'name',
'private_key_uuid',
'enable_ipv6',
'disable_public_ipv4',
'vultr_ssh_key_ids',
'cloud_init_script',
'instant_validate',
];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
'region' => 'required|string',
'plan' => 'required|string',
'os_id' => 'required|integer',
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
'private_key_uuid' => 'required|string',
'enable_ipv6' => 'nullable|boolean',
'disable_public_ipv4' => 'nullable|boolean',
'vultr_ssh_key_ids' => 'nullable|array',
'vultr_ssh_key_ids.*' => 'string',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'instant_validate' => 'nullable|boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
}
if (! $request->name) {
$request->offsetSet('name', generate_random_name());
}
if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true);
}
if (is_null($request->disable_public_ipv4)) {
$request->offsetSet('disable_public_ipv4', false);
}
if (is_null($request->vultr_ssh_key_ids)) {
$request->offsetSet('vultr_ssh_key_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
}
if ($request->disable_public_ipv4 && ! $request->enable_ipv6) {
return $this->networkConfigurationErrorResponse();
}
$token = CloudProviderToken::whereTeamId($teamId)
->whereUuid($this->getCloudProviderTokenUuid($request))
->where('provider', 'vultr')
->first();
if (! $token) {
return response()->json(['message' => 'Vultr cloud provider token not found.'], 404);
}
$this->authorize('view', $token);
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
if (! $privateKey) {
return response()->json(['message' => 'Private key not found.'], 404);
}
try {
$vultrService = new VultrService($token->token);
$publicKey = $privateKey->getPublicKey();
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
if ($existingKey) {
$sshKeyId = $existingKey['id'];
} else {
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
$sshKeyId = $uploadedKey['id'];
}
$normalizedServerName = strtolower(trim($request->name));
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $request->vultr_ssh_key_ids)));
$params = [
'region' => $request->region,
'plan' => $request->plan,
'os_id' => $request->os_id,
'label' => $normalizedServerName,
'hostname' => $normalizedServerName,
'sshkey_id' => $sshKeys,
'enable_ipv6' => $request->enable_ipv6,
'disable_public_ipv4' => $request->disable_public_ipv4,
];
if (! empty($request->cloud_init_script)) {
$params['user_data'] = $request->cloud_init_script;
}
$vultrInstance = $vultrService->createInstance($params);
$ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => $normalizedServerName,
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => $teamId,
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$ipAddress = $assignedIpAddress;
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($request->instant_validate) {
ValidateServer::dispatch($server);
}
auditLog('api.vultr_server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
]);
return response()->json([
'uuid' => $server->uuid,
'vultr_instance_id' => $vultrInstance['id'],
'ip' => $ipAddress,
])->setStatusCode(201);
} catch (RateLimitException $e) {
$response = response()->json(['message' => $e->getMessage()], 429);
if ($e->retryAfter !== null) {
$response->header('Retry-After', $e->retryAfter);
}
return $response;
} catch (\Throwable) {
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
}
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
foreach ($sshKeys as $sshKey) {
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
return $sshKey;
}
}
return null;
}
private function normalizePublicKey(string $publicKey): string
{
$parts = preg_split('/\s+/', trim($publicKey));
return implode(' ', array_slice($parts ?: [], 0, 2));
}
private function networkConfigurationErrorResponse(): JsonResponse
{
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'enable_ipv6' => ['Enable IPv6 when disabling public IPv4.'],
],
], 422);
}
}

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

@ -12,6 +12,7 @@
use App\Http\Middleware\DecideWhatToDoWithUser; use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies; use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled; use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember; use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance; use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
@ -110,5 +111,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class, 'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class, 'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class, 'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
]; ];
} }

View file

@ -11,8 +11,9 @@ public function handle(Request $request, Closure $next)
{ {
$token = $request->user()->currentAccessToken(); $token = $request->user()->currentAccessToken();
$hasTokenPermission = $token->can('root') || $token->can('read:sensitive'); $hasTokenPermission = $token->can('root') || $token->can('read:sensitive');
$teamId = (int) data_get($token, 'team_id'); $teamId = data_get($token, 'team_id');
$isAdmin = $teamId ? $request->user()->isAdminOfTeam($teamId) : false; // team_id 0 is the instance-admin team, so a falsy check must not exclude it
$isAdmin = ! is_null($teamId) ? $request->user()->isAdminOfTeam((int) $teamId) : false;
// Allow access to sensitive data only if token has permission AND user is admin/owner // Allow access to sensitive data only if token has permission AND user is admin/owner
$request->attributes->add([ $request->attributes->add([

View file

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTeamMcpEnabled
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $user?->currentAccessToken()?->team_id;
$team = $user?->teams()
->where('teams.id', $teamId)
->first();
if (! $team?->is_mcp_server_enabled) {
return response()->json(['message' => 'MCP server is disabled for this team.'], 403);
}
return $next($request);
}
}

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

@ -12,6 +12,7 @@
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
/** /**
@ -53,11 +54,13 @@ public function handle(): void
/** /**
* Get all functional servers to check for orphaned containers. * Get all functional servers to check for orphaned containers.
*/ */
private function getServersToCheck(): \Illuminate\Support\Collection private function getServersToCheck(): Collection
{ {
$query = Server::whereRelation('settings', 'is_usable', true) $query = Server::whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_reachable', true)
->where('ip', '!=', '1.2.3.4'); ->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
if (isCloud()) { if (isCloud()) {
$query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true); $query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true);
@ -99,7 +102,7 @@ private function cleanupOrphanedContainersOnServer(Server $server): void
/** /**
* Get all PR containers on a server (containers with pullRequestId > 0). * Get all PR containers on a server (containers with pullRequestId > 0).
*/ */
private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection private function getPRContainersOnServer(Server $server): Collection
{ {
try { try {
$output = instant_remote_process([ $output = instant_remote_process([

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

@ -96,7 +96,16 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return; return;
} }
if (self::shouldSkipDeployAny([$this->pullRequestTitle])) { $repository_parts = explode('/', $this->fullName);
$owner = $repository_parts[0] ?? '';
$repo = $repository_parts[1] ?? '';
$headCommitMessage = null;
if ($this->action === 'opened' || $this->action === 'synchronize' || $this->action === 'reopened') {
$headCommitMessage = getGithubCommitMessage($githubApp, $owner, $repo, $this->commitSha);
}
if (self::shouldSkipDeployAny([$this->pullRequestTitle, $headCommitMessage])) {
return; return;
} }
@ -120,9 +129,6 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
// Get changed files for watch path filtering // Get changed files for watch path filtering
$changed_files = collect(); $changed_files = collect();
$repository_parts = explode('/', $this->fullName);
$owner = $repository_parts[0] ?? '';
$repo = $repository_parts[1] ?? '';
if ($this->action === 'synchronize' && $this->beforeSha && $this->afterSha) { if ($this->action === 'synchronize' && $this->beforeSha && $this->afterSha) {
// For synchronize events, get files changed between before and after commits // For synchronize events, get files changed between before and after commits

View file

@ -311,6 +311,10 @@ public function handle()
} }
} }
if (! $this->isCompleteSnapshot()) {
return;
}
$this->updateProxyStatus(); $this->updateProxyStatus();
$this->updateNotFoundApplicationStatus(); $this->updateNotFoundApplicationStatus();
@ -329,6 +333,11 @@ public function handle()
$this->checkLogDrainContainer(); $this->checkLogDrainContainer();
} }
private function isCompleteSnapshot(): bool
{
return data_get($this->data, 'snapshot.complete', true) !== false;
}
private function loadApplications(): Collection private function loadApplications(): Collection
{ {
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
$database->status = $containerStatus; $database->status = $containerStatus;
$database->save(); $database->save();
} }
if (! $this->isCompleteSnapshot()) {
return;
}
if ($this->isRunning($containerStatus) && $tcpProxy) { if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';

View file

@ -457,7 +457,9 @@ private function processDockerCleanup(Server $server): void
private function getServersForCleanupQuery(): Builder private function getServersForCleanupQuery(): Builder
{ {
$query = Server::with('settings') $query = Server::with('settings')
->where('ip', '!=', '1.2.3.4'); ->whereNotNull('ip')
->where('ip', '!=', '')
->whereNotIn('ip', Server::PLACEHOLDER_IPS);
if (isCloud()) { if (isCloud()) {
$query $query

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

@ -0,0 +1,59 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function __construct(public Server $server)
{
$this->onQueue('high');
}
public function middleware(): array
{
return [(new WithoutOverlapping('server-cloud-provider-status-'.$this->server->uuid))->expireAfter(130)->dontRelease()];
}
public function handle(): void
{
try {
if (! $this->server->cloudProviderToken) {
return;
}
match ($this->server->cloudProviderToken->provider) {
'hetzner' => $this->server->hetzner_server_id
? $this->server->refreshHetznerState()
: null,
'vultr' => $this->server->vultr_instance_id
? $this->server->refreshVultrState()
: null,
'digitalocean' => $this->server->digitalocean_droplet_id
? $this->server->refreshDigitalOceanState()
: null,
default => null,
};
} catch (\Throwable $e) {
Log::debug('Cloud provider status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -6,7 +6,6 @@
use App\Helpers\SshMultiplexingHelper; use App\Helpers\SshMultiplexingHelper;
use App\Models\Server; use App\Models\Server;
use App\Services\ConfigurationRepository; use App\Services\ConfigurationRepository;
use App\Services\HetznerService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
@ -42,8 +41,12 @@ private function disableSshMux(): void
$configRepository->disableSshMux(); $configRepository->disableSshMux();
} }
public function handle() public function handle(): void
{ {
if ($this->server->hasPlaceholderIp()) {
return;
}
$wasReachable = (bool) $this->server->settings->is_reachable; $wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent; $wasNotified = (bool) $this->server->unreachable_notification_sent;
@ -62,11 +65,6 @@ public function handle()
return; return;
} }
// Check Hetzner server status if applicable
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
$this->checkHetznerStatus();
}
// Temporarily disable mux if requested // Temporarily disable mux if requested
if ($this->disableMux) { if ($this->disableMux) {
$this->disableSshMux(); $this->disableSshMux();
@ -128,17 +126,6 @@ public function handle()
public function failed(?\Throwable $exception): void public function failed(?\Throwable $exception): void
{ {
if ($exception instanceof TimeoutExceededException) { if ($exception instanceof TimeoutExceededException) {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list. // Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete(); $this->job?->delete();
} }
@ -163,29 +150,6 @@ private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $w
} }
} }
private function checkHetznerStatus(): void
{
$status = null;
try {
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable) {
// Silently ignore — server may have been deleted from Hetzner.
}
if ($this->server->hetzner_server_status !== $status) {
$this->server->update(['hetzner_server_status' => $status]);
$this->server->hetzner_server_status = $status;
if ($status === 'off') {
ray('Server is powered off, marking as unreachable');
throw new \Exception('Server is powered off');
}
}
}
private function checkConnection(): bool private function checkConnection(): bool
{ {
try { try {

View file

@ -55,6 +55,13 @@ public function handle(): void
// Get all servers to process // Get all servers to process
$servers = $this->getServers(); $servers = $this->getServers();
// Provider state checks run independently so slow APIs cannot block SSH checks.
$this->dispatchCloudProviderStatusChecks($servers);
$servers = $servers
->reject(fn (Server $server) => $server->hasPlaceholderIp())
->values();
// Dispatch ServerConnectionCheck for all servers efficiently // Dispatch ServerConnectionCheck for all servers efficiently
$this->dispatchConnectionChecks($servers); $this->dispatchConnectionChecks($servers);
@ -64,24 +71,45 @@ public function handle(): void
private function getServers(): Collection private function getServers(): Collection
{ {
$allServers = Server::with('settings')->where('ip', '!=', '1.2.3.4'); $allServers = Server::with(['settings', 'cloudProviderToken']);
if (isCloud()) { if (isCloud()) {
$servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get(); $servers = $allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->get();
$own = Team::find(0)->servers()->with('settings')->get(); $own = Team::find(0)->servers()->with(['settings', 'cloudProviderToken'])->get();
return $servers->merge($own); return $servers->merge($own)->unique('id')->values();
} else { } else {
return $allServers->get(); return $allServers->get();
} }
} }
private function dispatchCloudProviderStatusChecks(Collection $servers): void
{
if (! shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-cloud-provider-status-checks', $this->executionTime)) {
return;
}
$servers->each(function (Server $server) {
$hasCloudResource = $server->hetzner_server_id
|| $server->vultr_instance_id
|| $server->digitalocean_droplet_id;
if ($hasCloudResource && $server->cloudProviderToken) {
ServerCloudProviderStatusCheckJob::dispatch($server);
}
});
}
private function dispatchConnectionChecks(Collection $servers): void private function dispatchConnectionChecks(Collection $servers): void
{ {
if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) { if (shouldRunCronNow($this->checkFrequency, $this->instanceTimezone, 'server-connection-checks', $this->executionTime)) {
$servers->each(function (Server $server) { $servers->each(function (Server $server) {
try { try {
if ($server->hasPlaceholderIp()) {
return;
}
// Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity // Skip SSH connection check if Sentinel is healthy — its heartbeat already proves connectivity
if ($server->isSentinelEnabled() && $server->isSentinelLive()) { if ($server->isSentinelEnabled() && $server->isSentinelLive()) {
return; return;

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'])
@ -1136,6 +1134,12 @@ private function loadCreatableItems()
public function navigateToResource($type) public function navigateToResource($type)
{ {
if ($type === 'server') {
$this->dispatch('closeSearchModal');
return redirectRoute($this, 'server.create');
}
// Find the item by type - check regular items first, then services // Find the item by type - check regular items first, then services
$item = collect($this->creatableItems)->firstWhere('type', $type); $item = collect($this->creatableItems)->firstWhere('type', $type);

View file

@ -192,13 +192,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.');
} }
@ -207,13 +200,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

@ -286,6 +286,7 @@ public function saveStopGracePeriod()
$this->application->settings->save(); $this->application->settings->save();
$this->dispatch('success', 'Stop grace period updated.'); $this->dispatch('success', 'Stop grace period updated.');
$this->dispatch('configurationChanged');
} catch (ValidationException $e) { } catch (ValidationException $e) {
throw $e; throw $e;
} 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._\-\/]*$/'],
@ -489,6 +489,7 @@ public function instantSave()
if ($this->isContainerLabelReadonlyEnabled) { if ($this->isContainerLabelReadonlyEnabled) {
$this->resetDefaultLabels(false); $this->resetDefaultLabels(false);
} }
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -771,16 +772,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 +855,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

@ -147,6 +147,7 @@ public function changeSource($sourceId, $sourceType)
'source_id' => $source->id, 'source_id' => $source->id,
'source_type' => $sourceType, 'source_type' => $sourceType,
]); ]);
$this->dispatch('configurationChanged');
['repository' => $customRepository] = $this->application->customRepository(); ['repository' => $customRepository] = $this->application->customRepository();
$repository = githubApi($this->application->source, "repos/{$customRepository}"); $repository = githubApi($this->application->source, "repos/{$customRepository}");

View file

@ -57,6 +57,7 @@ public function instantSave()
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.'); $this->dispatch('success', 'Swarm settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -68,6 +69,7 @@ public function submit()
$this->authorize('update', $this->application); $this->authorize('update', $this->application);
$this->syncData(true); $this->syncData(true);
$this->dispatch('success', 'Swarm settings updated.'); $this->dispatch('success', 'Swarm settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

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

@ -76,6 +76,7 @@ public function instantSave()
$this->resource->settings->save(); $this->resource->settings->save();
$this->getDevView(); $this->getDevView();
$this->dispatch('success', 'Environment variable settings updated.'); $this->dispatch('success', 'Environment variable settings updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -93,6 +94,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 +124,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 +172,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

@ -152,6 +152,7 @@ public function instantSave()
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save(); $this->resource->save();
$this->dispatch('success', 'Health check updated.'); $this->dispatch('success', 'Health check updated.');
$this->dispatch('configurationChanged');
} }
public function submit() public function submit()
@ -178,6 +179,7 @@ public function submit()
$this->resource->custom_healthcheck_found = $this->customHealthcheckFound; $this->resource->custom_healthcheck_found = $this->customHealthcheckFound;
$this->resource->save(); $this->resource->save();
$this->dispatch('success', 'Health check updated.'); $this->dispatch('success', 'Health check updated.');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -213,6 +215,7 @@ public function toggleHealthcheck()
} else { } else {
$this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.'); $this->dispatch('success', 'Health check '.($this->healthCheckEnabled ? 'enabled' : 'disabled').'.');
} }
$this->dispatch('configurationChanged');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }

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

@ -91,9 +91,7 @@ public function deleteTag(string $id)
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);
$this->resource->tags()->detach($id); $this->resource->tags()->detach($id);
$found_more_tags = Tag::ownedByCurrentTeam()->find($id); $found_more_tags = Tag::ownedByCurrentTeam()->find($id);
if ($found_more_tags && $found_more_tags->applications()->count() == 0 && $found_more_tags->services()->count() == 0) { $found_more_tags?->deleteIfOrphaned();
$found_more_tags->delete();
}
$this->refresh(); $this->refresh();
$this->dispatch('success', 'Tag deleted.'); $this->dispatch('success', 'Tag deleted.');
} catch (\Exception $e) { } catch (\Exception $e) {

View file

@ -0,0 +1,97 @@
<?php
namespace App\Livewire\Security\CloudInitScript;
use App\Models\CloudInitScript;
use App\Rules\ValidCloudInitYaml;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public CloudInitScript $cloudInitScript;
public string $name = '';
public string $script = '';
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new ValidCloudInitYaml],
];
}
protected function messages(): array
{
return [
'name.required' => 'Script name is required.',
'name.max' => 'Script name cannot exceed 255 characters.',
'script.required' => 'Cloud-init script content is required.',
];
}
public function mount(string $cloud_init_script_uuid): void
{
try {
$this->cloudInitScript = CloudInitScript::ownedByCurrentTeam()
->whereUuid($cloud_init_script_uuid)
->firstOrFail();
$this->authorize('view', $this->cloudInitScript);
$this->name = $this->cloudInitScript->name;
$this->script = $this->cloudInitScript->script;
} catch (AuthorizationException) {
abort(403, 'You do not have permission to view this cloud-init script.');
} catch (\Throwable) {
abort(404);
}
}
public function save(): void
{
$this->authorize('update', $this->cloudInitScript);
$this->validate();
$this->cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
auditLog('ui.cloud_init_script.updated', [
'team_id' => currentTeam()->id,
'cloud_init_script_id' => $this->cloudInitScript->id,
'cloud_init_script_name' => $this->cloudInitScript->name,
]);
$this->dispatch('success', 'Cloud-init script updated successfully.');
}
public function delete(): mixed
{
$this->authorize('delete', $this->cloudInitScript);
$scriptId = $this->cloudInitScript->id;
$scriptName = $this->cloudInitScript->name;
$this->cloudInitScript->delete();
auditLog('ui.cloud_init_script.deleted', [
'team_id' => currentTeam()->id,
'cloud_init_script_id' => $scriptId,
'cloud_init_script_name' => $scriptName,
]);
return redirectRoute($this, 'security.cloud-init-scripts');
}
public function render()
{
return view('livewire.security.cloud-init-script.show');
}
}

View file

@ -27,6 +27,13 @@ public function getListeners()
public function loadScripts() public function loadScripts()
{ {
CloudInitScript::ownedByCurrentTeam()
->whereNull('uuid')
->get()
->each(function (CloudInitScript $script): void {
$script->forceFill(['uuid' => new_public_id()])->save();
});
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get(); $this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
} }

View file

@ -0,0 +1,177 @@
<?php
namespace App\Livewire\Security\CloudProviderToken;
use App\Models\CloudProviderToken;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public CloudProviderToken $cloudProviderToken;
public string $name = '';
public ?string $description = null;
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
];
}
protected function messages(): array
{
return [
'name.required' => 'Token name is required.',
];
}
public function mount(string $cloud_token_uuid): void
{
try {
$this->cloudProviderToken = CloudProviderToken::ownedByCurrentTeam()
->whereUuid($cloud_token_uuid)
->firstOrFail();
$this->authorize('view', $this->cloudProviderToken);
$this->name = $this->cloudProviderToken->name;
$this->description = $this->cloudProviderToken->description;
} catch (AuthorizationException) {
abort(403, 'You do not have permission to view this cloud token.');
} catch (\Throwable) {
abort(404);
}
}
public function save(): void
{
$this->authorize('update', $this->cloudProviderToken);
$this->validate();
$description = trim($this->description ?? '');
$this->cloudProviderToken->update([
'name' => $this->name,
'description' => $description === '' ? null : $description,
]);
auditLog('ui.cloud_token.updated', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
]);
$this->dispatch('success', 'Cloud provider token updated.');
}
public function validateToken(): void
{
$this->authorize('view', $this->cloudProviderToken);
$isValid = match ($this->cloudProviderToken->provider) {
'hetzner' => $this->validateHetznerToken($this->cloudProviderToken->token),
'digitalocean' => $this->validateDigitalOceanToken($this->cloudProviderToken->token),
'vultr' => $this->validateVultrToken($this->cloudProviderToken->token),
default => false,
};
$providerName = $this->providerName();
$this->dispatch(
$isValid ? 'success' : 'error',
$isValid
? "{$providerName} token is valid."
: "{$providerName} token validation failed. Please check the token."
);
auditLog('ui.cloud_token.validated', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
'valid' => $isValid,
]);
}
public function delete(): mixed
{
$this->authorize('delete', $this->cloudProviderToken);
if ($this->cloudProviderToken->hasServers()) {
$serverCount = $this->cloudProviderToken->servers()->count();
$this->dispatch('error', "Cannot delete this token. It is currently used by {$serverCount} server(s). Please reassign those servers to a different token first.");
return null;
}
auditLog('ui.cloud_token.deleted', [
'team_id' => currentTeam()->id,
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
'cloud_token_name' => $this->cloudProviderToken->name,
'provider' => $this->cloudProviderToken->provider,
]);
$this->cloudProviderToken->delete();
return redirectRoute($this, 'security.cloud-tokens');
}
public function providerName(): string
{
return match ($this->cloudProviderToken->provider) {
'digitalocean' => 'DigitalOcean',
'vultr' => 'Vultr',
default => 'Hetzner',
};
}
private function validateHetznerToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.hetzner.cloud/v1/servers?per_page=1')
->successful();
} catch (\Throwable) {
return false;
}
}
private function validateDigitalOceanToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.digitalocean.com/v2/account')
->successful();
} catch (\Throwable) {
return false;
}
}
private function validateVultrToken(string $token): bool
{
try {
return Http::withToken($token)
->timeout(10)
->get('https://api.vultr.com/v2/account')
->successful();
} catch (\Throwable) {
return false;
}
}
public function render()
{
return view('livewire.security.cloud-provider-token.show');
}
}

View file

@ -2,6 +2,9 @@
namespace App\Livewire\Security; namespace App\Livewire\Security;
use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow;
use App\Livewire\Server\New\ByDigitalOcean;
use App\Livewire\Server\New\ByHetzner;
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@ -19,6 +22,8 @@ class CloudProviderTokenForm extends Component
public string $name = ''; public string $name = '';
public ?string $description = null;
public function mount() public function mount()
{ {
try { try {
@ -31,9 +36,10 @@ public function mount()
protected function rules(): array protected function rules(): array
{ {
return [ return [
'provider' => 'required|string|in:hetzner,digitalocean', 'provider' => 'required|string|in:hetzner,digitalocean,vultr',
'token' => 'required|string', 'token' => 'required|string',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'description' => 'nullable|string|max:1000',
]; ];
} }
@ -58,8 +64,22 @@ private function validateToken(string $provider, string $token): bool
return $response->successful(); return $response->successful();
} }
// Add other providers here in the future if ($provider === 'digitalocean') {
// if ($provider === 'digitalocean') { ... } $response = Http::withToken($token)
->acceptJson()
->timeout(10)
->get('https://api.digitalocean.com/v2/account');
return $response->successful();
}
if ($provider === 'vultr') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.vultr.com/v2/account');
return $response->successful();
}
return false; return false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -77,11 +97,14 @@ public function addToken()
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.'); return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
} }
$description = trim($this->description ?? '');
$savedToken = CloudProviderToken::create([ $savedToken = CloudProviderToken::create([
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
'provider' => $this->provider, 'provider' => $this->provider,
'token' => $this->token, 'token' => $this->token,
'name' => $this->name, 'name' => $this->name,
'description' => $description === '' ? null : $description,
]); ]);
auditLog('ui.cloud_token.created', [ auditLog('ui.cloud_token.created', [
@ -91,10 +114,24 @@ public function addToken()
'provider' => $savedToken->provider, 'provider' => $savedToken->provider,
]); ]);
$this->reset(['token', 'name']); $this->reset(['token', 'name', 'description']);
// Dispatch event with token ID so parent components can react // Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id); $this->dispatch('tokenAdded', tokenId: $savedToken->id);
$this->dispatch('tokenAdded', tokenId: $savedToken->id)->to(CloudProviderTokens::class);
if ($savedToken->provider === 'digitalocean') {
$this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class);
}
if ($savedToken->provider === 'hetzner') {
$this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ByHetzner::class);
$this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class);
}
if ($this->modal_mode) {
$this->dispatch('close-modal');
}
$this->dispatch('success', 'Cloud provider token added successfully.'); $this->dispatch('success', 'Cloud provider token added successfully.');
} catch (\Throwable $e) { } catch (\Throwable $e) {

View file

@ -55,6 +55,13 @@ public function validateToken(int $tokenId)
} else { } else {
$this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.'); $this->dispatch('error', 'DigitalOcean token validation failed. Please check the token.');
} }
} elseif ($token->provider === 'vultr') {
$isValid = $this->validateVultrToken($token->token);
if ($isValid) {
$this->dispatch('success', 'Vultr token is valid.');
} else {
$this->dispatch('error', 'Vultr token validation failed. Please check the token.');
}
} else { } else {
$this->dispatch('error', 'Unknown provider.'); $this->dispatch('error', 'Unknown provider.');
} }
@ -97,6 +104,19 @@ private function validateDigitalOceanToken(string $token): bool
} }
} }
private function validateVultrToken(string $token): bool
{
try {
$response = Http::withToken($token)
->timeout(10)
->get('https://api.vultr.com/v2/account');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
public function deleteToken(int $tokenId) public function deleteToken(int $tokenId)
{ {
try { try {

View file

@ -43,22 +43,6 @@ protected function messages(): array
); );
} }
public function generateNewRSAKey()
{
$this->generateNewKey('rsa');
}
public function generateNewEDKey()
{
$this->generateNewKey('ed25519');
}
private function generateNewKey($type)
{
$keyData = PrivateKey::generateNewKeyPair($type);
$this->setKeyData($keyData);
}
public function updated($property) public function updated($property)
{ {
if ($property === 'value') { if ($property === 'value') {
@ -93,14 +77,6 @@ public function createPrivateKey()
} }
} }
private function setKeyData(array $keyData)
{
$this->name = $keyData['name'];
$this->description = $keyData['description'];
$this->value = $keyData['private_key'];
$this->publicKey = $keyData['public_key'];
}
private function validatePrivateKey() private function validatePrivateKey()
{ {
$validationResult = PrivateKey::validateAndExtractPublicKey($this->value); $validationResult = PrivateKey::validateAndExtractPublicKey($this->value);

View file

@ -10,6 +10,31 @@ class Index extends Component
{ {
use AuthorizesRequests; use AuthorizesRequests;
public function generatePrivateKey(string $type)
{
try {
$this->authorize('create', PrivateKey::class);
if (! in_array($type, ['ed25519', 'rsa'], true)) {
$this->dispatch('error', 'Invalid private key type.');
return;
}
$keyData = PrivateKey::generateNewKeyPair($type);
$privateKey = PrivateKey::createAndStore([
'name' => $keyData['name'],
'description' => $keyData['description'],
'private_key' => $keyData['private_key'],
'team_id' => currentTeam()->id,
]);
return redirectRoute($this, 'security.private-key.show', ['private_key_uuid' => $privateKey->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render() public function render()
{ {
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get(); $privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description', 'team_id'])->get();

View file

@ -4,6 +4,7 @@
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component; use Livewire\Component;
@ -22,8 +23,12 @@ class Show extends Component
public bool $isGitRelated = false; public bool $isGitRelated = false;
public bool $isInUse = false;
public $public_key = 'Loading...'; public $public_key = 'Loading...';
public string $deleteDisabledReason = 'This private key is currently used by a server, application, or Git app and cannot be deleted.';
protected function rules(): array protected function rules(): array
{ {
return [ return [
@ -74,16 +79,17 @@ private function syncData(bool $toModel = false): void
} }
} }
public function mount() public function mount(?string $private_key_uuid = null)
{ {
try { try {
$this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid(request()->private_key_uuid)->firstOrFail(); $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related', 'team_id'])->whereUuid($private_key_uuid ?? request()->private_key_uuid)->firstOrFail();
// Explicit authorization check - will throw 403 if not authorized // Explicit authorization check - will throw 403 if not authorized
$this->authorize('view', $this->private_key); $this->authorize('view', $this->private_key);
$this->syncData(false); $this->syncData(false);
} catch (\Illuminate\Auth\Access\AuthorizationException $e) { $this->isInUse = $this->private_key->isInUse();
} catch (AuthorizationException $e) {
abort(403, 'You do not have permission to view this private key.'); abort(403, 'You do not have permission to view this private key.');
} catch (\Throwable) { } catch (\Throwable) {
abort(404); abort(404);
@ -102,7 +108,15 @@ public function delete()
{ {
try { try {
$this->authorize('delete', $this->private_key); $this->authorize('delete', $this->private_key);
$this->private_key->safeDelete();
if ($this->private_key->isInUse()) {
$this->isInUse = true;
$this->dispatch('error', $this->deleteDisabledReason);
return;
}
$this->private_key->delete();
currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get();
return redirectRoute($this, 'security.private-key.index'); return redirectRoute($this, 'security.private-key.index');

View file

@ -18,6 +18,10 @@ class Show extends Component
public $parameters = []; public $parameters = [];
public string $provider = 'hetzner';
public string $providerName = 'Hetzner';
public function mount(string $server_uuid) public function mount(string $server_uuid)
{ {
try { try {
@ -31,14 +35,17 @@ public function mount(string $server_uuid)
public function getListeners() public function getListeners()
{ {
return [ return [
'tokenAdded' => 'handleTokenAdded', 'tokenAdded.hetzner' => 'handleTokenAdded',
]; ];
} }
public function loadTokens() public function loadTokens()
{ {
$this->provider = $this->server->vultr_instance_id ? 'vultr' : 'hetzner';
$this->providerName = $this->provider === 'vultr' ? 'Vultr' : 'Hetzner';
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam() $this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner') ->where('provider', $this->provider)
->get(); ->get();
} }
@ -78,7 +85,7 @@ public function setCloudProviderToken($tokenId)
'provider' => $ownedToken->provider, 'provider' => $ownedToken->provider,
]); ]);
$this->dispatch('success', 'Hetzner token updated successfully.'); $this->dispatch('success', "{$this->providerName} token updated successfully.");
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
} catch (\Exception $e) { } catch (\Exception $e) {
$this->server->refresh(); $this->server->refresh();
@ -89,10 +96,13 @@ public function setCloudProviderToken($tokenId)
private function validateTokenForServer(CloudProviderToken $token): array private function validateTokenForServer(CloudProviderToken $token): array
{ {
try { try {
// First, validate the token itself $endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token, 'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); ])->timeout(10)->get($endpoint);
if (! $response->successful()) { if (! $response->successful()) {
return [ return [
@ -101,7 +111,6 @@ private function validateTokenForServer(CloudProviderToken $token): array
]; ];
} }
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) { if ($this->server->hetzner_server_id) {
$serverResponse = Http::withHeaders([ $serverResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token, 'Authorization' => 'Bearer '.$token->token,
@ -115,6 +124,19 @@ private function validateTokenForServer(CloudProviderToken $token): array
} }
} }
if ($this->server->vultr_instance_id) {
$serverResponse = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.vultr.com/v2/instances/{$this->server->vultr_instance_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this instance. It may belong to a different Vultr account.',
];
}
}
return ['valid' => true]; return ['valid' => true];
} catch (\Throwable $e) { } catch (\Throwable $e) {
return [ return [
@ -129,19 +151,23 @@ public function validateToken()
try { try {
$token = $this->server->cloudProviderToken; $token = $this->server->cloudProviderToken;
if (! $token) { if (! $token) {
$this->dispatch('error', 'No Hetzner token is associated with this server.'); $this->dispatch('error', "No {$this->providerName} token is associated with this server.");
return; return;
} }
$endpoint = $token->provider === 'vultr'
? 'https://api.vultr.com/v2/account'
: 'https://api.hetzner.cloud/v1/servers';
$response = Http::withHeaders([ $response = Http::withHeaders([
'Authorization' => 'Bearer '.$token->token, 'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'); ])->timeout(10)->get($endpoint);
if ($response->successful()) { if ($response->successful()) {
$this->dispatch('success', 'Hetzner token is valid and working.'); $this->dispatch('success', "{$this->providerName} token is valid and working.");
} else { } else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.'); $this->dispatch('error', "{$this->providerName} token is invalid or has insufficient permissions.");
} }
auditLog('ui.server.cloud_token_validated', [ auditLog('ui.server.cloud_token_validated', [

View file

@ -11,12 +11,21 @@ class Create extends Component
{ {
public $private_keys = []; public $private_keys = [];
public ?string $selectedType = null;
public ?string $selectedTokenUuid = null;
public bool $limit_reached = false; public bool $limit_reached = false;
public bool $has_hetzner_tokens = false; public bool $has_hetzner_tokens = false;
public function mount() public function mount(?string $selectedType = null, ?string $selectedTokenUuid = null): void
{ {
$this->selectedType = in_array($selectedType, ['hetzner', 'vultr', 'digital-ocean', 'manual'], true)
? $selectedType
: null;
$this->selectedTokenUuid = $this->selectedType && $this->selectedType !== 'manual' ? $selectedTokenUuid : null;
$this->private_keys = PrivateKey::ownedByCurrentTeamCached(); $this->private_keys = PrivateKey::ownedByCurrentTeamCached();
if (! isCloud()) { if (! isCloud()) {
$this->limit_reached = false; $this->limit_reached = false;

View file

@ -0,0 +1,55 @@
<?php
namespace App\Livewire\Server;
use App\Models\CloudProviderToken;
use Illuminate\Contracts\View\View;
use Livewire\Component;
class CreatePage extends Component
{
public ?string $type = null;
public ?string $token_uuid = null;
public string $title = 'New Server';
public ?string $tokenProvider = null;
public ?string $tokenProviderName = null;
public bool $hasProviderTokens = false;
public function mount(?string $type = null, ?string $token_uuid = null): void
{
$this->type = $type;
$this->token_uuid = $token_uuid;
$this->tokenProvider = match ($type) {
'hetzner' => 'hetzner',
'vultr' => 'vultr',
'digital-ocean' => 'digitalocean',
default => null,
};
$this->tokenProviderName = match ($type) {
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
default => null,
};
$this->hasProviderTokens = $this->tokenProvider
? CloudProviderToken::ownedByCurrentTeam()->where('provider', $this->tokenProvider)->exists()
: false;
$this->title = match ($type) {
'hetzner' => 'Hetzner',
'vultr' => 'Vultr',
'digital-ocean' => 'DigitalOcean',
'manual' => 'Manual',
default => 'New Server',
};
}
public function render(): View
{
return view('livewire.server.create-page');
}
}

View file

@ -16,6 +16,10 @@ class Delete extends Component
public bool $delete_from_hetzner = false; public bool $delete_from_hetzner = false;
public bool $delete_from_vultr = false;
public bool $delete_from_digitalocean = false;
public bool $force_delete_resources = false; public bool $force_delete_resources = false;
public function mount(string $server_uuid) public function mount(string $server_uuid)
@ -35,6 +39,8 @@ public function delete($password, $selectedActions = [])
if (! empty($selectedActions)) { if (! empty($selectedActions)) {
$this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions);
$this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions);
$this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
} }
try { try {
@ -57,7 +63,11 @@ public function delete($password, $selectedActions = [])
$this->delete_from_hetzner, $this->delete_from_hetzner,
$this->server->hetzner_server_id, $this->server->hetzner_server_id,
$this->server->cloud_provider_token_id, $this->server->cloud_provider_token_id,
$this->server->team_id $this->server->team_id,
$this->delete_from_vultr,
$this->server->vultr_instance_id,
$this->delete_from_digitalocean,
$this->server->digitalocean_droplet_id
); );
return redirectRoute($this, 'server.index'); return redirectRoute($this, 'server.index');
@ -87,6 +97,22 @@ public function render()
]; ];
} }
if ($this->server->vultr_instance_id) {
$checkboxes[] = [
'id' => 'delete_from_vultr',
'label' => 'Also delete server from Vultr',
'default_warning' => 'The actual server on Vultr will NOT be deleted.',
];
}
if ($this->server->digitalocean_droplet_id) {
$checkboxes[] = [
'id' => 'delete_from_digitalocean',
'label' => 'Also delete droplet from DigitalOcean',
'default_warning' => 'The actual droplet on DigitalOcean will NOT be deleted.',
];
}
return view('livewire.server.delete', [ return view('livewire.server.delete', [
'checkboxes' => $checkboxes, 'checkboxes' => $checkboxes,
]); ]);

View file

@ -39,6 +39,7 @@ public function getListeners()
return [ return [
'refreshServerShow' => 'refreshServer', 'refreshServerShow' => 'refreshServer',
"echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification', "echo-private:team.{$teamId},ProxyStatusChangedUI" => 'showNotification',
"echo-private:team.{$teamId},SentinelRestarted" => 'refreshSentinelStatus',
]; ];
} }
@ -203,6 +204,15 @@ public function refreshServer()
$this->server->load('settings'); $this->server->load('settings');
} }
public function refreshSentinelStatus($event = null): void
{
if (isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
return;
}
$this->refreshServer();
}
/** /**
* Check if Traefik has any outdated version info (patch or minor upgrade). * Check if Traefik has any outdated version info (patch or minor upgrade).
* This shows a warning indicator in the navbar. * This shows a warning indicator in the navbar.

View file

@ -0,0 +1,546 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\DigitalOceanService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByDigitalOcean extends Component
{
use AuthorizesRequests;
public int $current_step = 1;
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
public array $regions = [];
public array $images = [];
public array $sizes = [];
public array $digitalOceanSshKeys = [];
public ?string $selected_region = null;
public string|int|null $selected_image = null;
public ?string $selected_size = null;
public array $selectedDigitalOceanSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv6 = true;
public bool $monitoring = true;
public bool $show_cloud_init_script = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount(?string $selectedTokenUuid = null)
{
try {
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function loadSavedCloudInitScripts(): void
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function getListeners(): array
{
return [
'tokenAdded.digitalocean' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function resetSelection(): void
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
$this->show_cloud_init_script = false;
$this->selectedDigitalOceanSshKeyIds = [];
}
public function loadTokens(): void
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'digitalocean')
->get();
}
public function handleTokenAdded($tokenId): void
{
$this->loadTokens();
$this->selected_token_id = $tokenId;
$this->nextStep();
}
public function handlePrivateKeyCreated($keyId): void
{
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
$this->private_key_id = $keyId;
$this->resetErrorBag('private_key_id');
}
protected function rules(): array
{
$rules = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_region' => 'required|string',
'selected_image' => 'required',
'selected_size' => 'required|string',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedDigitalOceanSshKeyIds' => 'nullable|array',
'selectedDigitalOceanSshKeyIds.*' => 'integer',
'enable_ipv6' => 'required|boolean',
'monitoring' => 'required|boolean',
'show_cloud_init_script' => 'boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a DigitalOcean token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId): mixed
{
$this->selected_token_id = $tokenId;
return $this->nextStep();
}
private function selectTokenFromUrl(?string $selectedTokenUuid): void
{
if (! $selectedTokenUuid) {
return;
}
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
if (! $token) {
return;
}
$this->selectedTokenUuid = $selectedTokenUuid;
$this->selected_token_id = $token->id;
}
private function getDigitalOceanToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
public function nextStep()
{
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if ($token) {
return $this->redirectRoute('server.create.token', [
'type' => 'digital-ocean',
'token_uuid' => $token->uuid,
], navigate: true);
}
}
$this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function previousStep(): mixed
{
if ($this->selectedTokenUuid) {
return $this->redirectRoute('server.create.type', ['type' => 'digital-ocean'], navigate: true);
}
$this->current_step = 1;
return null;
}
public function loadDigitalOceanData(): void
{
$token = $this->getDigitalOceanToken();
if (! $token) {
$this->loading_data = false;
$this->dispatch('error', 'Please select a valid DigitalOcean token.');
return;
}
$this->loading_data = true;
$this->provider_data_error = null;
try {
$digitalOceanService = new DigitalOceanService($token);
$this->regions = $digitalOceanService->getRegions();
$this->sizes = $digitalOceanService->getSizes();
$this->images = collect($digitalOceanService->getImages())
->sortBy(fn (array $image) => ($image['distribution'] ?? '').' '.($image['name'] ?? ''))
->values()
->toArray();
$this->digitalOceanSshKeys = $digitalOceanService->getSshKeys();
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
$this->provider_data_error = $this->providerDataErrorMessage('DigitalOcean', $e, 'message');
$this->dispatch('error', $this->provider_data_error);
}
}
private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string
{
$details = $e->getMessage();
if ($e instanceof RequestException && $e->response) {
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
}
public function getAvailableSizesProperty(): array
{
if (! $this->selected_region) {
return $this->sizes;
}
return collect($this->sizes)
->filter(fn (array $size) => in_array($this->selected_region, $size['regions'] ?? []))
->values()
->toArray();
}
public function getAvailableImagesProperty(): array
{
if (! $this->selected_region) {
return $this->images;
}
return collect($this->images)
->filter(fn (array $image) => in_array($this->selected_region, $image['regions'] ?? []))
->values()
->toArray();
}
public function getSelectedDropletPriceProperty(): ?string
{
if (! $this->selected_size) {
return null;
}
$size = collect($this->sizes)->firstWhere('slug', $this->selected_size);
if (! $size || ! isset($size['price_monthly'])) {
return null;
}
return '$'.number_format((float) $size['price_monthly'], 2);
}
public function updatedSelectedRegion(): void
{
$this->selected_size = null;
$this->selected_image = null;
}
public function updatedSelectedSize(): void
{
$this->selected_image = null;
}
public function updatedSelectedCloudInitScriptId($value): void
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
$this->show_cloud_init_script = true;
}
}
public function updatedSaveCloudInitScript(bool $value): void
{
if (! $value) {
$this->cloud_init_script_name = null;
}
}
public function showCloudInitScript(): void
{
$this->show_cloud_init_script = true;
}
public function getAdvancedDigitalOceanOptionsSummaryProperty(): array
{
$summary = [];
if (count($this->selectedDigitalOceanSshKeyIds) > 0) {
$summary[] = count($this->selectedDigitalOceanSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedDigitalOceanSshKeyIds));
}
if (! $this->enable_ipv6) {
$summary[] = 'IPv4 only';
}
if (! $this->monitoring) {
$summary[] = 'Monitoring off';
}
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
$summary[] = 'Cloud-init';
}
return $summary;
}
public function clearCloudInitScript(): void
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
$this->show_cloud_init_script = false;
}
/**
* Create the droplet on DigitalOcean and return the raw droplet payload.
* The public IP may not be assigned yet at this point.
*/
private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array
{
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
$sshKeyId = null;
foreach ($digitalOceanService->getSshKeys() as $key) {
if (($key['fingerprint'] ?? null) === $md5Fingerprint) {
$sshKeyId = (int) $key['id'];
break;
}
}
if (! $sshKeyId) {
$uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey());
$sshKeyId = (int) $uploadedKey['id'];
}
$sshKeys = array_values(array_unique(array_merge(
[$sshKeyId],
$this->selectedDigitalOceanSshKeyIds
)));
$params = [
'name' => strtolower(trim($this->server_name)),
'region' => $this->selected_region,
'size' => $this->selected_size,
'image' => $this->selected_image,
'ssh_keys' => $sshKeys,
'ipv6' => $this->enable_ipv6,
'monitoring' => $this->monitoring,
];
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
return $digitalOceanService->createDroplet($params);
}
public function submit()
{
$this->validate();
$digitalOceanService = null;
$dropletId = null;
$server = null;
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->cloud_init_script_name,
'script' => $this->cloud_init_script,
]);
}
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
$dropletId = (int) $droplet['id'];
// Persist the server immediately so the droplet is always tracked
// in Coolify, even if waiting for the public IP fails below.
$server = DB::transaction(function () use ($dropletId, $droplet): Server {
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'digitalocean_droplet_id' => $dropletId,
'digitalocean_droplet_status' => $droplet['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return $server;
});
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
}
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
return handleError($e, $this);
}
}
private function deleteUntrackedDroplet(?DigitalOceanService $digitalOceanService, ?int $dropletId, ?Server $server): void
{
if (! $digitalOceanService || ! $dropletId || $server) {
return;
}
try {
$digitalOceanService->deleteDroplet($dropletId);
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
return view('livewire.server.new.by-digital-ocean');
}
}

View file

@ -12,6 +12,7 @@
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\HetznerService; use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Locked; use Livewire\Attributes\Locked;
@ -37,6 +38,8 @@ class ByHetzner extends Component
// Step 1: Token selection // Step 1: Token selection
public ?int $selected_token_id = null; public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
// Step 2: Server configuration // Step 2: Server configuration
public array $locations = []; public array $locations = [];
@ -46,6 +49,10 @@ class ByHetzner extends Component
public array $hetznerSshKeys = []; public array $hetznerSshKeys = [];
public array $hetznerFirewalls = [];
public array $hetznerNetworks = [];
public ?string $selected_location = null; public ?string $selected_location = null;
public ?int $selected_image = null; public ?int $selected_image = null;
@ -54,16 +61,26 @@ class ByHetzner extends Component
public array $selectedHetznerSshKeyIds = []; public array $selectedHetznerSshKeyIds = [];
public array $selectedHetznerFirewallIds = [];
public array $selectedHetznerNetworkIds = [];
public string $server_name = ''; public string $server_name = '';
public ?int $private_key_id = null; public ?int $private_key_id = null;
public bool $loading_data = false; public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv4 = true; public bool $enable_ipv4 = true;
public bool $enable_ipv6 = true; public bool $enable_ipv6 = true;
public bool $enable_backups = false;
public bool $show_cloud_init_script = false;
public ?string $cloud_init_script = null; public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false; public bool $save_cloud_init_script = false;
@ -77,11 +94,12 @@ class ByHetzner extends Component
public bool $from_onboarding = false; public bool $from_onboarding = false;
public function mount() public function mount(?string $selectedTokenUuid = null)
{ {
try { try {
$this->authorize('viewAny', CloudProviderToken::class); $this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens(); $this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts(); $this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name(); $this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
@ -89,6 +107,11 @@ public function mount()
if ($this->private_keys->count() > 0) { if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id; $this->private_key_id = $this->private_keys->first()->id;
} }
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -102,7 +125,7 @@ public function loadSavedCloudInitScripts()
public function getListeners() public function getListeners()
{ {
return [ return [
'tokenAdded' => 'handleTokenAdded', 'tokenAdded.hetzner' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated', 'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection', 'modalClosed' => 'resetSelection',
]; ];
@ -112,10 +135,15 @@ public function resetSelection()
{ {
$this->selected_token_id = null; $this->selected_token_id = null;
$this->current_step = 1; $this->current_step = 1;
$this->enable_backups = false;
$this->cloud_init_script = null; $this->cloud_init_script = null;
$this->save_cloud_init_script = false; $this->save_cloud_init_script = false;
$this->cloud_init_script_name = null; $this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null; $this->selected_cloud_init_script_id = null;
$this->show_cloud_init_script = false;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
} }
public function loadTokens() public function loadTokens()
@ -164,8 +192,14 @@ protected function rules(): array
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array', 'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer', 'selectedHetznerSshKeyIds.*' => 'integer',
'selectedHetznerFirewallIds' => 'nullable|array',
'selectedHetznerFirewallIds.*' => 'integer',
'selectedHetznerNetworkIds' => 'nullable|array',
'selectedHetznerNetworkIds.*' => 'integer',
'enable_ipv4' => 'required|boolean', 'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean', 'enable_ipv6' => 'required|boolean',
'enable_backups' => 'required|boolean',
'show_cloud_init_script' => 'boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean', 'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255', 'cloud_init_script_name' => 'nullable|string|max:255',
@ -184,9 +218,27 @@ protected function messages(): array
]; ];
} }
public function selectToken(int $tokenId) public function selectToken(int $tokenId): mixed
{ {
$this->selected_token_id = $tokenId; $this->selected_token_id = $tokenId;
return $this->nextStep();
}
private function selectTokenFromUrl(?string $selectedTokenUuid): void
{
if (! $selectedTokenUuid) {
return;
}
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
if (! $token) {
return;
}
$this->selectedTokenUuid = $selectedTokenUuid;
$this->selected_token_id = $token->id;
} }
private function validateHetznerToken(string $token): bool private function validateHetznerToken(string $token): bool
@ -221,17 +273,20 @@ public function nextStep()
]); ]);
try { try {
$hetznerToken = $this->getHetznerToken(); if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if (! $hetznerToken) { if ($token) {
return $this->dispatch('error', 'Please select a valid Hetzner token.'); return $this->redirectRoute('server.create.token', [
'type' => 'hetzner',
'token_uuid' => $token->uuid,
], navigate: true);
}
} }
// Load Hetzner data // Move to step 2; provider data is loaded after initial render via wire:init.
$this->loadHetznerData($hetznerToken);
// Move to step 2
$this->current_step = 2; $this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
} }
@ -239,12 +294,29 @@ public function nextStep()
public function previousStep() public function previousStep()
{ {
if ($this->selectedTokenUuid) {
return $this->redirectRoute('server.create.type', ['type' => 'hetzner'], navigate: true);
}
$this->current_step = 1; $this->current_step = 1;
} }
private function loadHetznerData(string $token) public function loadHetznerData(): void
{ {
$token = $this->getHetznerToken();
if (! $token) {
$this->loading_data = false;
$this->dispatch('error', 'Please select a valid Hetzner token.');
return;
}
$this->loading_data = true; $this->loading_data = true;
$this->provider_data_error = null;
$this->selectedHetznerSshKeyIds = [];
$this->selectedHetznerFirewallIds = [];
$this->selectedHetznerNetworkIds = [];
try { try {
$hetznerService = new HetznerService($token); $hetznerService = new HetznerService($token);
@ -274,13 +346,33 @@ private function loadHetznerData(string $token)
->toArray(); ->toArray();
// Load SSH keys from Hetzner // Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys(); $this->hetznerSshKeys = $hetznerService->getSshKeys();
$this->hetznerFirewalls = collect($hetznerService->getFirewalls())
->sortBy('name')
->values()
->toArray();
$this->hetznerNetworks = collect($hetznerService->getNetworks())
->sortBy('name')
->values()
->toArray();
$this->loading_data = false; $this->loading_data = false;
} catch (\Throwable $e) { } catch (\Throwable $e) {
$this->loading_data = false; $this->loading_data = false;
throw $e; $this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message');
$this->dispatch('error', $this->provider_data_error);
} }
} }
private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string
{
$details = $e->getMessage();
if ($e instanceof RequestException && $e->response) {
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
}
private function getCpuVendorInfo(array $serverType): ?string private function getCpuVendorInfo(array $serverType): ?string
{ {
$name = strtolower($serverType['name'] ?? ''); $name = strtolower($serverType['name'] ?? '');
@ -349,6 +441,37 @@ public function getAvailableImagesProperty()
return $filtered; return $filtered;
} }
public function getAvailableNetworksProperty(): array
{
$attachableNetworks = collect($this->hetznerNetworks)
->filter(function (array $network) {
return collect($network['subnets'] ?? [])->contains(function (array $subnet) {
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true);
});
});
if (! $this->selected_location) {
return $attachableNetworks->values()->toArray();
}
$location = collect($this->locations)->firstWhere('name', $this->selected_location);
$networkZone = $location['network_zone'] ?? null;
if (! $networkZone) {
return $attachableNetworks->values()->toArray();
}
return $attachableNetworks
->filter(function (array $network) use ($networkZone) {
return collect($network['subnets'] ?? [])->contains(function (array $subnet) use ($networkZone) {
return in_array($subnet['type'] ?? null, ['cloud', 'server'], true)
&& ($subnet['network_zone'] ?? null) === $networkZone;
});
})
->values()
->toArray();
}
public function getSelectedServerPriceProperty(): ?string public function getSelectedServerPriceProperty(): ?string
{ {
if (! $this->selected_server_type) { if (! $this->selected_server_type) {
@ -366,11 +489,74 @@ public function getSelectedServerPriceProperty(): ?string
return '€'.number_format($price, 2); return '€'.number_format($price, 2);
} }
public function getSelectedServerBackupSurchargeProperty(): ?string
{
if (! $this->selected_server_type) {
return null;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) {
return null;
}
$price = (float) $serverType['prices'][0]['price_monthly']['gross'];
return '€'.number_format($price * 0.2, 2);
}
public function getAdvancedHetznerOptionsSummaryProperty(): array
{
$summary = [];
if (count($this->selectedHetznerSshKeyIds) > 0) {
$summary[] = count($this->selectedHetznerSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedHetznerSshKeyIds));
}
if (count($this->selectedHetznerFirewallIds) > 0) {
$summary[] = count($this->selectedHetznerFirewallIds).' '.str('firewall')->plural(count($this->selectedHetznerFirewallIds));
}
if (count($this->selectedHetznerNetworkIds) > 0) {
$summary[] = count($this->selectedHetznerNetworkIds).' private '.str('network')->plural(count($this->selectedHetznerNetworkIds));
}
if ($this->enable_backups) {
$summary[] = 'Backups on';
}
if (! $this->enable_ipv4 || ! $this->enable_ipv6) {
$summary[] = collect([
$this->enable_ipv4 ? 'IPv4' : null,
$this->enable_ipv6 ? 'IPv6' : null,
])->filter()->join(' + ') ?: 'No public IP';
}
if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) {
$summary[] = 'Cloud-init';
}
return $summary;
}
public function showCloudInitScript(): void
{
$this->show_cloud_init_script = true;
}
public function updatedSelectedLocation($value) public function updatedSelectedLocation($value)
{ {
// Reset server type and image when location changes // Reset server type and image when location changes
$this->selected_server_type = null; $this->selected_server_type = null;
$this->selected_image = null; $this->selected_image = null;
$this->selectedHetznerNetworkIds = array_values(array_filter(
$this->selectedHetznerNetworkIds,
function (int $selectedNetworkId): bool {
return collect($this->availableNetworks)->contains('id', $selectedNetworkId);
}
));
} }
public function updatedSelectedServerType($value) public function updatedSelectedServerType($value)
@ -390,6 +576,14 @@ public function updatedSelectedCloudInitScriptId($value)
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script; $this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name; $this->cloud_init_script_name = $script->name;
$this->show_cloud_init_script = true;
}
}
public function updatedSaveCloudInitScript(bool $value): void
{
if (! $value) {
$this->cloud_init_script_name = null;
} }
} }
@ -399,12 +593,11 @@ public function clearCloudInitScript()
$this->cloud_init_script = ''; $this->cloud_init_script = '';
$this->cloud_init_script_name = ''; $this->cloud_init_script_name = '';
$this->save_cloud_init_script = false; $this->save_cloud_init_script = false;
$this->show_cloud_init_script = false;
} }
private function createHetznerServer(string $token): array private function createHetznerServer(HetznerService $hetznerService): array
{ {
$hetznerService = new HetznerService($token);
// Get the private key and extract public key // Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
@ -458,6 +651,18 @@ private function createHetznerServer(string $token): array
], ],
]; ];
$firewallIds = array_values(array_unique($this->selectedHetznerFirewallIds));
if ($firewallIds !== []) {
$params['firewalls'] = array_map(function (int $firewallId): array {
return ['firewall' => $firewallId];
}, $firewallIds);
}
$networkIds = array_values(array_unique($this->selectedHetznerNetworkIds));
if ($networkIds !== []) {
$params['networks'] = $networkIds;
}
// Add cloud-init script if provided // Add cloud-init script if provided
if (! empty($this->cloud_init_script)) { if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script; $params['user_data'] = $this->cloud_init_script;
@ -473,6 +678,13 @@ public function submit()
{ {
$this->validate(); $this->validate();
if (! $this->enable_ipv4 && ! $this->enable_ipv6) {
$this->addError('enable_ipv4', 'Enable at least one public IP protocol.');
$this->addError('enable_ipv6', 'Enable at least one public IP protocol.');
return null;
}
try { try {
$this->authorize('create', Server::class); $this->authorize('create', Server::class);
@ -492,9 +704,10 @@ public function submit()
} }
$hetznerToken = $this->getHetznerToken(); $hetznerToken = $this->getHetznerToken();
$hetznerService = new HetznerService($hetznerToken);
// Create server on Hetzner // Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerToken); $hetznerServer = $this->createHetznerServer($hetznerService);
// Determine IP address to use (prefer IPv4, fallback to IPv6) // Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null; $ipAddress = null;
@ -504,26 +717,33 @@ public function submit()
$ipAddress = $hetznerServer['public_net']['ipv6']['ip']; $ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
} }
if (! $ipAddress) { // Create server in Coolify database immediately so the Hetzner
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); // server is always tracked, even when no IP is assigned yet —
} // the server page polling backfills the placeholder IP later.
// Create server in Coolify database
$server = Server::create([ $server = Server::create([
'name' => $this->server_name, 'name' => $this->server_name,
'ip' => $ipAddress, 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
'user' => 'root', 'user' => 'root',
'port' => 22, 'port' => 22,
'team_id' => currentTeam()->id, 'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id, 'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id, 'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'], 'hetzner_server_id' => $hetznerServer['id'],
'hetzner_server_status' => $hetznerServer['status'] ?? null,
]); ]);
$server->proxy->set('status', 'exited'); $server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save(); $server->save();
if ($this->enable_backups) {
try {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
} catch (\Throwable $e) {
report($e);
}
}
if ($this->from_onboarding) { if ($this->from_onboarding) {
// Complete the boarding when server is successfully created via Hetzner // Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([ currentTeam()->update([

View file

@ -3,6 +3,7 @@
namespace App\Livewire\Server\New; namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Models\PrivateKey;
use App\Models\Server; use App\Models\Server;
use App\Models\Team; use App\Models\Team;
use App\Rules\ValidServerIp; use App\Rules\ValidServerIp;
@ -84,11 +85,51 @@ protected function messages(): array
]); ]);
} }
public function getListeners(): array
{
return [
'privateKeyCreated' => 'handlePrivateKeyCreated',
];
}
public function setPrivateKey(string $private_key_id) public function setPrivateKey(string $private_key_id)
{ {
$this->private_key_id = $private_key_id; $this->private_key_id = $private_key_id;
} }
public function generatePrivateKey(string $type): void
{
try {
$this->authorize('create', PrivateKey::class);
if (! in_array($type, ['ed25519', 'rsa'], true)) {
$this->dispatch('error', 'Invalid private key type.');
return;
}
$keyData = PrivateKey::generateNewKeyPair($type);
$privateKey = PrivateKey::createAndStore([
'name' => $keyData['name'],
'description' => $keyData['description'],
'private_key' => $keyData['private_key'],
'team_id' => currentTeam()->id,
]);
$this->handlePrivateKeyCreated($privateKey->id);
$this->dispatch('success', 'Private key created successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function handlePrivateKeyCreated($keyId): void
{
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
$this->private_key_id = $keyId;
$this->resetErrorBag('private_key_id');
}
public function instantSave() public function instantSave()
{ {
// $this->dispatch('success', 'Application settings updated!'); // $this->dispatch('success', 'Application settings updated!');

View file

@ -0,0 +1,521 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\VultrService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByVultr extends Component
{
use AuthorizesRequests;
public int $current_step = 1;
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
public ?int $selected_token_id = null;
public ?string $selectedTokenUuid = null;
public array $regions = [];
public array $plans = [];
public array $operatingSystems = [];
public array $vultrSshKeys = [];
public ?string $selected_region = null;
public ?string $selected_plan = null;
public ?int $selected_os_id = null;
public array $selectedVultrSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public ?string $provider_data_error = null;
public bool $enable_ipv6 = true;
public bool $disable_public_ipv4 = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount(?string $selectedTokenUuid = null): void
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->selectTokenFromUrl($selectedTokenUuid);
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
if ($this->selectedTokenUuid) {
$this->current_step = 2;
$this->loading_data = true;
}
}
public function getListeners(): array
{
return [
'tokenAdded' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function loadTokens(): void
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'vultr')
->get();
}
public function loadSavedCloudInitScripts(): void
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function resetSelection(): void
{
$this->selected_token_id = null;
$this->current_step = 1;
$this->cloud_init_script = null;
$this->save_cloud_init_script = false;
$this->cloud_init_script_name = null;
$this->selected_cloud_init_script_id = null;
}
public function handleTokenAdded($tokenId): void
{
$this->loadTokens();
$this->selected_token_id = $tokenId;
$this->nextStep();
}
public function handlePrivateKeyCreated($keyId): void
{
$this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get();
$this->private_key_id = $keyId;
$this->resetErrorBag('private_key_id');
}
protected function rules(): array
{
$rules = [
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
];
if ($this->current_step === 2) {
$rules = array_merge($rules, [
'server_name' => ['required', 'string', 'max:253', new ValidHostname],
'selected_region' => 'required|string',
'selected_plan' => 'required|string',
'selected_os_id' => 'required|integer',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedVultrSshKeyIds' => 'nullable|array',
'selectedVultrSshKeyIds.*' => 'string',
'enable_ipv6' => 'required|boolean',
'disable_public_ipv4' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255',
'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id',
]);
}
return $rules;
}
protected function messages(): array
{
return [
'selected_token_id.required' => 'Please select a Vultr token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId): mixed
{
$this->selected_token_id = $tokenId;
return $this->nextStep();
}
private function selectTokenFromUrl(?string $selectedTokenUuid): void
{
if (! $selectedTokenUuid) {
return;
}
$token = $this->available_tokens->firstWhere('uuid', $selectedTokenUuid);
if (! $token) {
return;
}
$this->selectedTokenUuid = $selectedTokenUuid;
$this->selected_token_id = $token->id;
}
public function nextStep(): mixed
{
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
if (! $this->selectedTokenUuid) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
if ($token) {
return $this->redirectRoute('server.create.token', [
'type' => 'vultr',
'token_uuid' => $token->uuid,
], navigate: true);
}
}
$this->current_step = 2;
$this->loading_data = true;
} catch (\Throwable $e) {
return handleError($e, $this);
}
return null;
}
public function previousStep(): mixed
{
if ($this->selectedTokenUuid) {
return $this->redirectRoute('server.create.type', ['type' => 'vultr'], navigate: true);
}
$this->current_step = 1;
return null;
}
public function updatedSelectedRegion(): void
{
$this->selected_plan = null;
}
public function updatedSelectedCloudInitScriptId($value): void
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
}
}
public function clearCloudInitScript(): void
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
}
public function getAvailablePlansProperty(): array
{
if (! $this->selected_region) {
return $this->plans;
}
return collect($this->plans)
->filter(function ($plan) {
$locations = $plan['locations'] ?? [];
return empty($locations) || in_array($this->selected_region, $locations);
})
->values()
->toArray();
}
public function getSelectedServerPriceProperty(): ?string
{
if (! $this->selected_plan) {
return null;
}
$plan = collect($this->plans)->firstWhere('id', $this->selected_plan);
$monthlyCost = $plan['monthly_cost'] ?? null;
if ($monthlyCost === null) {
return null;
}
return '$'.number_format((float) $monthlyCost, 2);
}
public function getAdvancedVultrOptionsSummaryProperty(): array
{
$summary = [];
if (count($this->selectedVultrSshKeyIds) > 0) {
$summary[] = count($this->selectedVultrSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedVultrSshKeyIds));
}
if (! $this->enable_ipv6) {
$summary[] = 'IPv6 disabled';
}
if ($this->disable_public_ipv4) {
$summary[] = 'Public IPv4 disabled';
}
if (! empty($this->cloud_init_script)) {
$summary[] = 'cloud-init';
}
return $summary;
}
private function getVultrToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
public function loadVultrData(): void
{
$token = $this->getVultrToken();
if (! $token) {
$this->loading_data = false;
$this->dispatch('error', 'Please select a valid Vultr token.');
return;
}
$this->loading_data = true;
$this->provider_data_error = null;
try {
$vultrService = new VultrService($token);
$this->regions = collect($vultrService->getRegions())
->sortBy('id')
->values()
->toArray();
$this->plans = collect($vultrService->getPlans())
->sortBy('monthly_cost')
->values()
->toArray();
$this->operatingSystems = collect($vultrService->getOperatingSystems())
->sortBy('name')
->values()
->toArray();
$this->vultrSshKeys = $vultrService->getSshKeys();
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
$this->provider_data_error = $this->providerDataErrorMessage('Vultr', $e, 'error');
$this->dispatch('error', $this->provider_data_error);
}
}
private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string
{
$details = $e->getMessage();
if ($e instanceof RequestException && $e->response) {
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
}
private function createVultrServer(string $token): array
{
$vultrService = new VultrService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$existingKey = $this->findMatchingSshKey($vultrService->getSshKeys(), $publicKey);
if ($existingKey) {
$sshKeyId = $existingKey['id'];
} else {
$uploadedKey = $vultrService->uploadSshKey($privateKey->name, $publicKey);
$sshKeyId = $uploadedKey['id'];
}
$sshKeys = array_values(array_unique(array_merge([$sshKeyId], $this->selectedVultrSshKeyIds)));
$normalizedServerName = strtolower(trim($this->server_name));
$params = [
'region' => $this->selected_region,
'plan' => $this->selected_plan,
'os_id' => $this->selected_os_id,
'label' => $normalizedServerName,
'hostname' => $normalizedServerName,
'sshkey_id' => $sshKeys,
'enable_ipv6' => $this->enable_ipv6,
'disable_public_ipv4' => $this->disable_public_ipv4,
];
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
return $vultrService->createInstance($params);
}
public function submit(): mixed
{
$this->validate();
if (! $this->hasValidPublicNetworkConfiguration()) {
return null;
}
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) {
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->cloud_init_script_name,
'script' => $this->cloud_init_script,
]);
}
$vultrService = new VultrService($this->getVultrToken());
$vultrInstance = $this->createVultrServer($this->getVultrToken());
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'vultr_instance_id' => $vultrInstance['id'],
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
try {
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
]);
refreshSession();
}
return redirectRoute($this, 'server.show', [$server->uuid]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.new.by-vultr');
}
private function findMatchingSshKey(array $sshKeys, string $publicKey): ?array
{
$normalizedPublicKey = $this->normalizePublicKey($publicKey);
foreach ($sshKeys as $sshKey) {
if ($this->normalizePublicKey($sshKey['ssh_key'] ?? '') === $normalizedPublicKey) {
return $sshKey;
}
}
return null;
}
private function normalizePublicKey(string $publicKey): string
{
$parts = preg_split('/\s+/', trim($publicKey));
return implode(' ', array_slice($parts ?: [], 0, 2));
}
private function hasValidPublicNetworkConfiguration(): bool
{
if (! $this->disable_public_ipv4 || $this->enable_ipv6) {
return true;
}
$this->addError('enable_ipv6', 'Enable IPv6 when disabling public IPv4.');
return false;
}
}

View file

@ -55,6 +55,33 @@ public function setPrivateKey($privateKeyId)
} }
} }
public function generatePrivateKey(string $type): void
{
try {
$this->authorize('create', PrivateKey::class);
if (! in_array($type, ['ed25519', 'rsa'], true)) {
$this->dispatch('error', 'Invalid private key type.');
return;
}
$keyData = PrivateKey::generateNewKeyPair($type);
$privateKey = PrivateKey::createAndStore([
'name' => $keyData['name'],
'description' => $keyData['description'],
'private_key' => $keyData['private_key'],
'team_id' => currentTeam()->id,
]);
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get()->where('is_git_related', false);
$this->dispatch('copyPublicKeyToClipboard', publicKey: $privateKey->public_key);
$this->dispatch('success', 'Private key created successfully.');
} catch (\Throwable $e) {
handleError($e, $this);
}
}
public function checkConnection() public function checkConnection()
{ {
try { try {

View file

@ -8,7 +8,9 @@
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use App\Models\Server; use App\Models\Server;
use App\Rules\ValidServerIp; use App\Rules\ValidServerIp;
use App\Services\DigitalOceanService;
use App\Services\HetznerService; use App\Services\HetznerService;
use App\Services\VultrService;
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@ -75,8 +77,16 @@ class Show extends Component
public ?string $hetznerServerStatus = null; public ?string $hetznerServerStatus = null;
public ?string $vultrInstanceStatus = null;
public ?string $digitalOceanDropletStatus = null;
public bool $hetznerServerManuallyStarted = false; public bool $hetznerServerManuallyStarted = false;
public bool $vultrInstanceManuallyStarted = false;
public bool $digitalOceanDropletManuallyStarted = false;
public bool $isValidating = false; public bool $isValidating = false;
// Hetzner linking properties // Hetzner linking properties
@ -92,6 +102,30 @@ class Show extends Component
public bool $hetznerNoMatchFound = false; public bool $hetznerNoMatchFound = false;
public Collection $availableVultrTokens;
public ?int $selectedVultrTokenId = null;
public ?string $manualVultrInstanceId = null;
public ?array $matchedVultrInstance = null;
public ?string $vultrSearchError = null;
public bool $vultrNoMatchFound = false;
public Collection $availableDigitalOceanTokens;
public ?int $selectedDigitalOceanTokenId = null;
public ?string $manualDigitalOceanDropletId = null;
public ?array $matchedDigitalOceanDroplet = null;
public ?string $digitalOceanSearchError = null;
public bool $digitalOceanNoMatchFound = false;
public function getListeners() public function getListeners()
{ {
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
@ -173,10 +207,14 @@ public function mount(string $server_uuid)
} }
// Load saved Hetzner status and validation state // Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status; $this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status;
$this->isValidating = $this->server->is_validating ?? false; $this->isValidating = $this->server->is_validating ?? false;
// Load Hetzner tokens for linking // Load cloud provider tokens for linking
$this->loadHetznerTokens(); $this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);
@ -289,6 +327,22 @@ public function validateServer($install = true)
{ {
try { try {
$this->authorize('update', $this->server); $this->authorize('update', $this->server);
if ($this->server->vultr_instance_id) {
$status = $this->server->refreshVultrState();
$this->server->refresh();
$this->vultrInstanceStatus = $this->server->vultr_instance_status;
$this->ip = $this->server->ip;
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$message = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$this->dispatch('error', $message);
return;
}
}
$this->validationLogs = $this->server->validation_logs = null; $this->validationLogs = $this->server->validation_logs = null;
$this->server->save(); $this->server->save();
$this->dispatch('init', $install); $this->dispatch('init', $install);
@ -434,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false)
$this->server->hetzner_server_status = $this->hetznerServerStatus; $this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
} }
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
if ($this->server->backfillPlaceholderIp($assignedIp)) {
$this->ip = $this->server->ip;
}
if ($manual) { if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
} }
@ -458,6 +517,49 @@ public function checkHetznerServerStatus(bool $manual = false)
} }
} }
public function checkVultrInstanceStatus(bool $manual = false)
{
try {
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
return;
}
$this->vultrInstanceStatus = $this->server->refreshVultrState();
$this->server->refresh();
$this->ip = $this->server->ip;
if ($manual) {
$this->dispatch('success', 'Instance status refreshed: '.ucfirst($this->vultrInstanceStatus ?? 'unknown'));
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function checkDigitalOceanDropletStatus(bool $manual = false)
{
try {
$this->authorize('view', $this->server);
if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.');
return;
}
$this->digitalOceanDropletStatus = $this->server->refreshDigitalOceanState();
$this->server->refresh();
$this->ip = $this->server->ip;
if ($manual) {
$this->dispatch('success', 'Droplet status refreshed: '.ucfirst($this->digitalOceanDropletStatus ?? 'unknown'));
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function handleServerValidated($event = null) public function handleServerValidated($event = null)
{ {
// Check if event is for this server // Check if event is for this server
@ -477,6 +579,8 @@ public function handleServerValidated($event = null)
// Reload Hetzner tokens in case the linking section should now be shown // Reload Hetzner tokens in case the linking section should now be shown
$this->loadHetznerTokens(); $this->loadHetznerTokens();
$this->loadVultrTokens();
$this->loadDigitalOceanTokens();
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
$this->dispatch('refreshServer'); $this->dispatch('refreshServer');
@ -504,6 +608,50 @@ public function startHetznerServer()
} }
} }
public function startVultrInstance()
{
try {
$this->authorize('update', $this->server);
if (! $this->server->vultr_instance_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Vultr instance or token.');
return;
}
$vultrService = new VultrService($this->server->cloudProviderToken->token);
$vultrService->startInstance($this->server->vultr_instance_id);
$this->vultrInstanceStatus = 'starting';
$this->server->update(['vultr_instance_status' => 'starting']);
$this->vultrInstanceManuallyStarted = true;
$this->dispatch('success', 'Vultr instance is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function startDigitalOceanDroplet()
{
try {
$this->authorize('update', $this->server);
if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.');
return;
}
$digitalOceanService = new DigitalOceanService($this->server->cloudProviderToken->token);
$digitalOceanService->powerOnDroplet((int) $this->server->digitalocean_droplet_id);
$this->digitalOceanDropletStatus = 'new';
$this->server->update(['digitalocean_droplet_status' => 'new']);
$this->digitalOceanDropletManuallyStarted = true;
$this->dispatch('success', 'DigitalOcean droplet is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshServerMetadata(): void public function refreshServerMetadata(): void
{ {
try { try {
@ -537,6 +685,34 @@ public function loadHetznerTokens(): void
->get(); ->get();
} }
public function loadVultrTokens(): void
{
$this->availableVultrTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'vultr')
->get();
}
public function loadDigitalOceanTokens(): void
{
$this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'digitalocean')
->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;
@ -658,6 +834,263 @@ public function linkToHetzner()
$this->hetznerSearchError = null; $this->hetznerSearchError = null;
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!'); $this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function searchDigitalOceanDroplet(): void
{
$this->digitalOceanSearchError = null;
$this->digitalOceanNoMatchFound = false;
$this->matchedDigitalOceanDroplet = null;
if (! $this->selectedDigitalOceanTokenId) {
$this->digitalOceanSearchError = 'Please select a DigitalOcean token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
$this->digitalOceanSearchError = 'Invalid token selected.';
return;
}
$digitalOceanService = new DigitalOceanService($token->token);
$matched = $digitalOceanService->findDropletByIp($this->server->ip);
if ($matched) {
$this->matchedDigitalOceanDroplet = $matched;
} else {
$this->digitalOceanNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->digitalOceanSearchError = 'Failed to search DigitalOcean droplets: '.$e->getMessage();
}
}
public function searchDigitalOceanDropletById(): void
{
$this->digitalOceanSearchError = null;
$this->digitalOceanNoMatchFound = false;
$this->matchedDigitalOceanDroplet = null;
if (! $this->selectedDigitalOceanTokenId) {
$this->digitalOceanSearchError = 'Please select a DigitalOcean token first.';
return;
}
if (! $this->manualDigitalOceanDropletId) {
$this->digitalOceanSearchError = 'Please enter a DigitalOcean Droplet ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
$this->digitalOceanSearchError = 'Invalid token selected.';
return;
}
$digitalOceanService = new DigitalOceanService($token->token);
$dropletData = $digitalOceanService->getDroplet((int) $this->manualDigitalOceanDropletId);
if (! empty($dropletData)) {
$this->matchedDigitalOceanDroplet = $dropletData;
} else {
$this->digitalOceanNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->digitalOceanSearchError = 'Failed to fetch DigitalOcean droplet: '.$e->getMessage();
}
}
public function linkToDigitalOcean()
{
if (! $this->matchedDigitalOceanDroplet) {
$this->dispatch('error', 'No DigitalOcean droplet selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
$digitalOceanService = new DigitalOceanService($token->token);
$dropletData = $digitalOceanService->getDroplet((int) $this->matchedDigitalOceanDroplet['id']);
if (empty($dropletData)) {
$this->dispatch('error', 'Could not find DigitalOcean droplet with ID: '.$this->matchedDigitalOceanDroplet['id']);
return;
}
$ip = $digitalOceanService->getPublicIpAddress($dropletData);
$updates = [
'cloud_provider_token_id' => $this->selectedDigitalOceanTokenId,
'digitalocean_droplet_id' => $this->matchedDigitalOceanDroplet['id'],
'digitalocean_droplet_status' => $dropletData['status'] ?? null,
];
if ($ip) {
$updates['ip'] = $ip;
}
$this->server->update($updates);
$this->digitalOceanDropletStatus = $dropletData['status'] ?? null;
$this->matchedDigitalOceanDroplet = null;
$this->selectedDigitalOceanTokenId = null;
$this->manualDigitalOceanDropletId = null;
$this->digitalOceanNoMatchFound = false;
$this->digitalOceanSearchError = null;
$this->dispatch('success', 'Server successfully linked to DigitalOcean!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function searchVultrInstance(): void
{
$this->vultrSearchError = null;
$this->vultrNoMatchFound = false;
$this->matchedVultrInstance = null;
if (! $this->selectedVultrTokenId) {
$this->vultrSearchError = 'Please select a Vultr token.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->vultrSearchError = 'Invalid token selected.';
return;
}
$vultrService = new VultrService($token->token);
$matched = $vultrService->findInstanceByIp($this->server->ip);
if ($matched) {
$this->matchedVultrInstance = $matched;
} else {
$this->vultrNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->vultrSearchError = 'Failed to search Vultr instances: '.$e->getMessage();
}
}
public function searchVultrInstanceById(): void
{
$this->vultrSearchError = null;
$this->vultrNoMatchFound = false;
$this->matchedVultrInstance = null;
if (! $this->selectedVultrTokenId) {
$this->vultrSearchError = 'Please select a Vultr token first.';
return;
}
if (! $this->manualVultrInstanceId) {
$this->vultrSearchError = 'Please enter a Vultr Instance ID.';
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->vultrSearchError = 'Invalid token selected.';
return;
}
$vultrService = new VultrService($token->token);
$instanceData = $vultrService->getInstance($this->manualVultrInstanceId);
if (! empty($instanceData)) {
$this->matchedVultrInstance = $instanceData;
} else {
$this->vultrNoMatchFound = true;
}
} catch (\Throwable $e) {
$this->vultrSearchError = 'Failed to fetch Vultr instance: '.$e->getMessage();
}
}
public function linkToVultr()
{
if (! $this->matchedVultrInstance) {
$this->dispatch('error', 'No Vultr instance selected.');
return;
}
try {
$this->authorize('update', $this->server);
$token = $this->availableVultrTokens->firstWhere('id', $this->selectedVultrTokenId);
if (! $token) {
$this->dispatch('error', 'Invalid token selected.');
return;
}
$vultrService = new VultrService($token->token);
$instanceData = $vultrService->getInstance($this->matchedVultrInstance['id']);
if (empty($instanceData)) {
$this->dispatch('error', 'Could not find Vultr instance with ID: '.$this->matchedVultrInstance['id']);
return;
}
$this->server->update([
'cloud_provider_token_id' => $this->selectedVultrTokenId,
'vultr_instance_id' => $this->matchedVultrInstance['id'],
'vultr_instance_status' => $instanceData['status'] ?? null,
]);
$this->vultrInstanceStatus = $instanceData['status'] ?? null;
$this->matchedVultrInstance = null;
$this->selectedVultrTokenId = null;
$this->manualVultrInstanceId = null;
$this->vultrNoMatchFound = false;
$this->vultrSearchError = null;
$this->dispatch('success', 'Server successfully linked to Vultr!');
$this->dispatch('close-modal');
$this->dispatch('refreshServerShow'); $this->dispatch('refreshServerShow');
} catch (\Throwable $e) { } catch (\Throwable $e) {
return handleError($e, $this); return handleError($e, $this);

View file

@ -92,6 +92,38 @@ public function validateConnection()
{ {
try { try {
$this->authorize('update', $this->server); $this->authorize('update', $this->server);
if ($this->server->vultr_instance_id) {
$status = $this->server->refreshVultrState();
$this->server->refresh();
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'Vultr instance is deleted or no longer accessible. Relink this server before validating.'
: 'Vultr instance is '.($status ?? 'not running').'. Power it on before validating.';
$this->server->update([
'validation_logs' => $this->error,
]);
return;
}
}
if ($this->server->digitalocean_droplet_id) {
$status = $this->server->refreshDigitalOceanState();
$this->server->refresh();
if (in_array($status, ['off', 'archive', 'deleted'], true)) {
$this->error = $status === 'deleted'
? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.'
: 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.';
$this->server->update([
'validation_logs' => $this->error,
]);
return;
}
}
['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection();
if (! $this->uptime) { if (! $this->uptime) {
$sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8');

View file

@ -46,6 +46,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 [
@ -60,6 +65,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',
]; ];
} }
@ -80,6 +87,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()
@ -146,13 +155,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);
@ -167,6 +184,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) {
@ -174,6 +193,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

@ -24,11 +24,14 @@ class Index extends Component
public ?string $description = null; public ?string $description = null;
public bool $is_mcp_server_enabled = true;
protected function rules(): array protected function rules(): array
{ {
return [ return [
'name' => ValidationPatterns::nameRules(), 'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(), 'description' => ValidationPatterns::descriptionRules(),
'is_mcp_server_enabled' => 'boolean',
]; ];
} }
@ -58,10 +61,12 @@ private function syncData(bool $toModel = false): void
// Sync TO model (before save) // Sync TO model (before save)
$this->team->name = $this->name; $this->team->name = $this->name;
$this->team->description = $this->description; $this->team->description = $this->description;
$this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled;
} else { } else {
// Sync FROM model (on load/refresh) // Sync FROM model (on load/refresh)
$this->name = $this->team->name; $this->name = $this->team->name;
$this->description = $this->team->description; $this->description = $this->team->description;
$this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled;
} }
} }

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)) {

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