Merge branch 'coollabsio:next' into next

This commit is contained in:
David Alejandro Londoño 2025-10-16 10:10:23 -05:00 committed by GitHub
commit a02240d143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
331 changed files with 24497 additions and 10900 deletions

156
.AI_INSTRUCTIONS_SYNC.md Normal file
View file

@ -0,0 +1,156 @@
# AI Instructions Synchronization Guide
This document explains how AI instructions are organized and synchronized across different AI tools used with Coolify.
## Overview
Coolify maintains AI instructions in two parallel systems:
1. **CLAUDE.md** - For Claude Code (claude.ai/code)
2. **.cursor/rules/** - For Cursor IDE and other AI assistants
Both systems share core principles but are optimized for their respective workflows.
## Structure
### CLAUDE.md
- **Purpose**: Condensed, workflow-focused guide for Claude Code
- **Format**: Single markdown file
- **Includes**:
- Quick-reference development commands
- High-level architecture overview
- Core patterns and guidelines
- Embedded Laravel Boost guidelines
- References to detailed .cursor/rules/ documentation
### .cursor/rules/
- **Purpose**: Detailed, topic-specific documentation
- **Format**: Multiple .mdc files organized by topic
- **Structure**:
- `README.mdc` - Main index and overview
- `cursor_rules.mdc` - Maintenance guidelines
- Topic-specific files (testing-patterns.mdc, security-patterns.mdc, etc.)
- **Used by**: Cursor IDE, Claude Code (for detailed reference), other AI assistants
## Cross-References
Both systems reference each other:
- **CLAUDE.md** → references `.cursor/rules/` for detailed documentation
- **.cursor/rules/README.mdc** → references `CLAUDE.md` for Claude Code workflow
- **.cursor/rules/cursor_rules.mdc** → notes that changes should sync with CLAUDE.md
## Maintaining Consistency
When updating AI instructions, follow these guidelines:
### 1. Core Principles (MUST be consistent)
- Laravel version (currently Laravel 12)
- PHP version (8.4)
- Testing execution rules (Docker for Feature tests, mocking for Unit tests)
- Security patterns and authorization requirements
- Code style requirements (Pint, PSR-12)
### 2. Where to Make Changes
**For workflow changes** (how to run commands, development setup):
- Primary: `CLAUDE.md`
- Secondary: `.cursor/rules/development-workflow.mdc`
**For architectural patterns** (how code should be structured):
- Primary: `.cursor/rules/` topic files
- Secondary: Reference in `CLAUDE.md` "Additional Documentation" section
**For testing patterns**:
- Both: Must be synchronized
- `CLAUDE.md` - Contains condensed testing execution rules
- `.cursor/rules/testing-patterns.mdc` - Contains detailed examples and patterns
### 3. Update Checklist
When making significant changes:
- [ ] Identify if change affects core principles (version numbers, critical patterns)
- [ ] Update primary location (CLAUDE.md or .cursor/rules/)
- [ ] Check if update affects cross-referenced content
- [ ] Update secondary location if needed
- [ ] Verify cross-references are still accurate
- [ ] Run: `./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc` (if applicable)
### 4. Common Inconsistencies to Watch
- **Version numbers**: Laravel, PHP, package versions
- **Testing instructions**: Docker execution requirements
- **File paths**: Ensure relative paths work from root
- **Command syntax**: Docker commands, artisan commands
- **Architecture decisions**: Laravel 10 structure vs Laravel 12+ structure
## File Organization
```
/
├── CLAUDE.md # Claude Code instructions (condensed)
├── .AI_INSTRUCTIONS_SYNC.md # This file
└── .cursor/
└── rules/
├── README.mdc # Index and overview
├── cursor_rules.mdc # Maintenance guide
├── testing-patterns.mdc # Testing details
├── development-workflow.mdc # Dev setup details
├── security-patterns.mdc # Security details
├── application-architecture.mdc
├── deployment-architecture.mdc
├── database-patterns.mdc
├── frontend-patterns.mdc
├── api-and-routing.mdc
├── form-components.mdc
├── technology-stack.mdc
├── project-overview.mdc
└── laravel-boost.mdc # Laravel-specific patterns
```
## Recent Updates
### 2025-10-07
- ✅ Added cross-references between CLAUDE.md and .cursor/rules/
- ✅ Synchronized Laravel version (12) across all files
- ✅ Added comprehensive testing execution rules (Docker for Feature tests)
- ✅ Added test design philosophy (prefer mocking over database)
- ✅ Fixed inconsistencies in testing documentation
- ✅ Created this synchronization guide
## Maintenance Commands
```bash
# Check for version inconsistencies
grep -r "Laravel [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Check for PHP version consistency
grep -r "PHP [0-9]" CLAUDE.md .cursor/rules/*.mdc
# Format all documentation
./vendor/bin/pint CLAUDE.md .cursor/rules/*.mdc
# Search for specific patterns across all docs
grep -r "pattern_to_check" CLAUDE.md .cursor/rules/
```
## Contributing
When contributing documentation:
1. Check both CLAUDE.md and .cursor/rules/ for existing documentation
2. Add to appropriate location(s) based on guidelines above
3. Add cross-references if creating new patterns
4. Update this file if changing organizational structure
5. Verify consistency before submitting PR
## Questions?
If unsure about where to document something:
- **Quick reference / workflow** → CLAUDE.md
- **Detailed patterns / examples** → .cursor/rules/[topic].mdc
- **Both?** → Start with .cursor/rules/, then reference in CLAUDE.md
When in doubt, prefer detailed documentation in .cursor/rules/ and concise references in CLAUDE.md.

View file

@ -9,6 +9,10 @@ alwaysApply: false
This comprehensive set of Cursor Rules provides deep insights into **Coolify**, an open-source self-hostable alternative to Heroku/Netlify/Vercel. These rules will help you understand, navigate, and contribute to this complex Laravel-based deployment platform.
> **Cross-Reference**: This directory is for **detailed, topic-specific rules** used by Cursor IDE and other AI assistants. For Claude Code specifically, also see **[CLAUDE.md](mdc:CLAUDE.md)** which provides a condensed, workflow-focused guide. Both systems share core principles but are optimized for their respective tools.
>
> **Maintaining Rules**: When updating these rules, see **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for synchronization guidelines to keep CLAUDE.md and .cursor/rules/ consistent.
## Rule Categories
### 🏗️ Architecture & Foundation
@ -71,7 +75,7 @@ Coolify uses a **team-based multi-tenancy** model where:
- **Multi-server** support with SSH connections
### 3. Technology Stack
- **Backend**: Laravel 11 + PHP 8.4
- **Backend**: Laravel 12 + PHP 8.4
- **Frontend**: Livewire 3.5 + Alpine.js + Tailwind CSS 4.1
- **Database**: PostgreSQL 15 + Redis 7
- **Containerization**: Docker + Docker Compose

View file

@ -4,6 +4,12 @@ globs: .cursor/rules/*.mdc
alwaysApply: true
---
# Cursor Rules Maintenance Guide
> **Important**: These rules in `.cursor/rules/` are shared between Cursor IDE and other AI assistants. Changes here should be reflected in **[CLAUDE.md](mdc:CLAUDE.md)** when they affect core workflows or patterns.
>
> **Synchronization Guide**: See **[.AI_INSTRUCTIONS_SYNC.md](mdc:.AI_INSTRUCTIONS_SYNC.md)** for detailed guidelines on maintaining consistency between CLAUDE.md and .cursor/rules/.
- **Required Rule Structure:**
```markdown
---

View file

@ -142,6 +142,29 @@ Schema::create('applications', function (Blueprint $table) {
- **Soft deletes** for audit trails
- **Activity logging** with Spatie package
### **CRITICAL: Mass Assignment Protection**
**When adding new database columns, you MUST update the model's `$fillable` array.** Without this, Laravel will silently ignore mass assignment operations like `Model::create()` or `$model->update()`.
**Checklist for new columns:**
1. ✅ Create migration file
2. ✅ Run migration
3. ✅ **Add column to model's `$fillable` array**
4. ✅ Update any Livewire components that sync this property
5. ✅ Test that the column can be read and written
**Example:**
```php
class Server extends BaseModel
{
protected $fillable = [
'name',
'ip',
'port',
'is_validating', // ← MUST add new columns here
];
}
```
### Relationship Patterns
```php
// Typical relationship structure in Application model

View file

@ -185,7 +185,7 @@ protected function isAccessible(User $user, ?string $path = null): bool
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.

View file

@ -5,11 +5,56 @@ alwaysApply: false
---
# Coolify Testing Architecture & Patterns
> **Cross-Reference**: These detailed testing patterns align with the testing guidelines in **[CLAUDE.md](mdc:CLAUDE.md)**. Both documents share the same core principles about Docker execution and mocking preferences.
## Testing Philosophy
Coolify employs **comprehensive testing strategies** using modern PHP testing frameworks to ensure reliability of deployment operations, infrastructure management, and user interactions.
!Important: Always run tests inside `coolify` container.
### Test Execution Rules
**CRITICAL**: Tests are categorized by database dependency:
#### Unit Tests (`tests/Unit/`)
- **MUST NOT** use database connections
- **MUST** use mocking for models and external dependencies
- **CAN** run outside Docker: `./vendor/bin/pest tests/Unit`
- Purpose: Test isolated logic, helper functions, and business rules
#### Feature Tests (`tests/Feature/`)
- **MAY** use database connections (factories, migrations, models)
- **MUST** run inside Docker container: `docker exec coolify php artisan test`
- **MUST** use `RefreshDatabase` trait if touching database
- Purpose: Test API endpoints, workflows, and integration scenarios
**Rule of thumb**: If your test needs `Server::factory()->create()` or any database operation, it's a Feature test and MUST run in Docker.
### Prefer Mocking Over Database
When writing tests, always prefer mocking over real database operations:
```php
// ❌ BAD: Unit test using database
it('extracts custom commands', function () {
$server = Server::factory()->create(['ip' => '1.2.3.4']);
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
// ✅ GOOD: Unit test using mocking
it('extracts custom commands', function () {
$server = Mockery::mock('App\Models\Server');
$server->shouldReceive('proxyType')->andReturn('traefik');
$commands = extract_custom_proxy_commands($server, $yaml);
expect($commands)->toBeArray();
});
```
**Design principles for testable code:**
- Use dependency injection instead of global state
- Create interfaces for external dependencies (SSH, Docker, etc.)
- Separate business logic from data persistence
- Make functions accept interfaces instead of concrete models when possible
## Testing Framework Stack

View file

@ -0,0 +1,24 @@
name: Cleanup Untagged GHCR Images
on:
workflow_dispatch: # Allow manual trigger
schedule:
- cron: '0 */6 * * *' # Run every 6 hours to handle large volume (16k+ images)
env:
GITHUB_REGISTRY: ghcr.io
jobs:
cleanup-testing-host:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Delete untagged coolify-testing-host images
uses: actions/delete-package-versions@v5
with:
package-name: 'coolify-testing-host'
package-type: 'container'
min-versions-to-keep: 0
delete-only-untagged-versions: 'true'

View file

@ -28,6 +28,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -50,8 +57,8 @@ jobs:
platforms: linux/amd64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
aarch64:
runs-on: [self-hosted, arm64]
@ -61,6 +68,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- name: Login to ${{ env.GITHUB_REGISTRY }}
uses: docker/login-action@v3
with:
@ -83,8 +97,8 @@ jobs:
platforms: linux/aarch64
push: true
tags: |
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64
${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64
merge-manifest:
runs-on: ubuntu-latest
@ -95,6 +109,13 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Sanitize branch name for Docker tag
id: sanitize
run: |
# Replace slashes and other invalid characters with dashes
SANITIZED_NAME=$(echo "${{ github.ref_name }}" | sed 's/[\/]/-/g')
echo "tag=${SANITIZED_NAME}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- name: Login to ${{ env.GITHUB_REGISTRY }}
@ -114,14 +135,14 @@ jobs:
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.GITHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- name: Create & publish manifest on ${{ env.DOCKER_REGISTRY }}
run: |
docker buildx imagetools create \
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
--append ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}-aarch64 \
--tag ${{ env.DOCKER_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.sanitize.outputs.tag }}
- uses: sarisia/actions-status-discord@v1
if: always()

13032
CHANGELOG.md

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,10 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
This file provides guidance to **Claude Code** (claude.ai/code) when working with code in this repository.
> **Note for AI Assistants**: This file is specifically for Claude Code. If you're using Cursor IDE, refer to the `.cursor/rules/` directory for detailed rule files. Both systems share core principles but are optimized for their respective workflows.
>
> **Maintaining Instructions**: When updating AI instructions, see [.AI_INSTRUCTIONS_SYNC.md](.AI_INSTRUCTIONS_SYNC.md) for synchronization guidelines between CLAUDE.md and .cursor/rules/.
## Project Overview
@ -23,7 +27,14 @@ ### Backend Development
### Code Quality
- `./vendor/bin/pint` - Run Laravel Pint for code formatting
- `./vendor/bin/phpstan` - Run PHPStan for static analysis
- `./vendor/bin/pest` - Run Pest tests
- `./vendor/bin/pest` - Run Pest tests (unit tests only, without database)
### Running Tests
**IMPORTANT**: Tests that require database connections MUST be run inside the Docker container:
- **Inside Docker**: `docker exec coolify php artisan test` (for feature tests requiring database)
- **Outside Docker**: `./vendor/bin/pest tests/Unit` (for pure unit tests without database dependencies)
- Unit tests should use mocking and avoid database connections
- Feature tests that require database must be run in the `coolify` container
## Architecture Overview
@ -149,6 +160,7 @@ ### Database Patterns
- Use database transactions for critical operations
- Leverage query scopes for reusable queries
- Apply indexes for performance-critical queries
- **CRITICAL**: When adding new database columns, ALWAYS update the model's `$fillable` array to allow mass assignment
### Security Best Practices
- **Authentication**: Multi-provider auth via Laravel Fortify & Sanctum
@ -173,6 +185,21 @@ ### Testing Strategy
- **Mocking**: Use Laravel's built-in mocking for external services
- **Database**: Use RefreshDatabase trait for test isolation
#### Test Execution Environment
**CRITICAL**: Database-dependent tests MUST run inside Docker container:
- **Unit Tests** (`tests/Unit/`): Should NOT use database. Use mocking. Run with `./vendor/bin/pest tests/Unit`
- **Feature Tests** (`tests/Feature/`): May use database. MUST run inside Docker with `docker exec coolify php artisan test`
- If a test needs database (factories, migrations, etc.), it belongs in `tests/Feature/`
- Always mock external services and SSH connections in tests
#### Test Design Philosophy
**PREFER MOCKING**: When designing features and writing tests:
- **Design for testability**: Structure code so it can be tested without database (use dependency injection, interfaces)
- **Mock by default**: Unit tests should mock models and external dependencies using Mockery
- **Avoid database when possible**: If you can test the logic without database, write it as a Unit test
- **Only use database when necessary**: Feature tests should test integration points, not isolated logic
- **Example**: Instead of `Server::factory()->create()`, use `Mockery::mock('App\Models\Server')` in unit tests
### Routing Conventions
- Group routes by middleware and prefix
- Use route model binding for cleaner controllers
@ -228,7 +255,9 @@ ## Important Reminders
## Additional Documentation
For more detailed guidelines and patterns, refer to the `.cursor/rules/` directory:
This file contains high-level guidelines for Claude Code. For **more detailed, topic-specific documentation**, refer to the `.cursor/rules/` directory (also accessible by Cursor IDE and other AI assistants):
> **Cross-Reference**: The `.cursor/rules/` directory contains comprehensive, detailed documentation organized by topic. Start with [.cursor/rules/README.mdc](.cursor/rules/README.mdc) for an overview, then explore specific topics below.
### Architecture & Patterns
- [Application Architecture](.cursor/rules/application-architecture.mdc) - Detailed application structure
@ -434,7 +463,7 @@ ### Laravel 10 Structure
### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
@ -543,6 +572,10 @@ ### Pest Tests
- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
- Tests should test all of the happy paths, failure paths, and weird paths.
- Tests live in the `tests/Feature` and `tests/Unit` directories.
- **Unit tests** MUST use mocking and avoid database. They can run outside Docker.
- **Feature tests** can use database but MUST run inside Docker container.
- **Design for testability**: Structure code to be testable without database when possible. Use dependency injection and interfaces.
- **Mock by default**: Prefer `Mockery::mock()` over `Model::factory()->create()` in unit tests.
- Pest tests look and behave like this:
<code-snippet name="Basic Pest Test Example" lang="php">
it('is true', function () {
@ -551,11 +584,23 @@ ### Pest Tests
</code-snippet>
### Running Tests
- Run the minimal number of tests using an appropriate filter before finalizing code edits.
- To run all tests: `php artisan test`.
- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
**IMPORTANT**: Always run tests in the correct environment based on database dependencies:
**Unit Tests (no database):**
- Run outside Docker: `./vendor/bin/pest tests/Unit`
- Run specific file: `./vendor/bin/pest tests/Unit/ProxyCustomCommandsTest.php`
- These tests use mocking and don't require PostgreSQL
**Feature Tests (with database):**
- Run inside Docker: `docker exec coolify php artisan test`
- Run specific file: `docker exec coolify php artisan test tests/Feature/ExampleTest.php`
- Filter by name: `docker exec coolify php artisan test --filter=testName`
- These tests require PostgreSQL and use factories/migrations
**General Guidelines:**
- Run the minimal number of tests using an appropriate filter before finalizing code edits
- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite
- If you get database connection errors, you're running a Feature test outside Docker - move it inside
### Pest Assertions
- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
@ -650,7 +695,12 @@ ### Replaced Utilities
## Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
- Run the minimum number of tests needed to ensure code quality and speed.
- **For Unit tests**: Use `./vendor/bin/pest tests/Unit/YourTest.php` (runs outside Docker)
- **For Feature tests**: Use `docker exec coolify php artisan test --filter=YourTest` (runs inside Docker)
- Choose the correct test type based on database dependency:
- No database needed? → Unit test with mocking
- Database needed? → Feature test in Docker
</laravel-boost-guidelines>

View file

@ -53,40 +53,40 @@ # Donations
## Big Sponsors
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [GlueOps](https://www.glueops.dev?ref=coolify.io) - DevOps automation and infrastructure management
* [23M](https://23m.com?ref=coolify.io) - Your experts for high-availability hosting solutions!
* [Algora](https://algora.io?ref=coolify.io) - Open source contribution platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Convex](https://convex.link/coolify.io) - Open-source reactive database for web app developers
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Trieve](https://trieve.ai?ref=coolify.io) - AI-powered search and analytics
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
* [Hetzner](http://htznr.li/CoolifyXHetzner) - Server, cloud, hosting, and data center solutions
* [COMIT](https://comit.international?ref=coolify.io) - New York Times awardwinning contractor
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [WZ-IT](https://wz-it.com/?ref=coolify.io) - German agency for customised cloud solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Hostinger](https://www.hostinger.com/vps/coolify-hosting?ref=coolify.io) - Web hosting and VPS solutions
* [QuantCDN](https://www.quantcdn.io?ref=coolify.io) - Enterprise-grade content delivery network
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [Cloudify.ro](https://cloudify.ro?ref=coolify.io) - Cloud hosting solutions
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
* [American Cloud](https://americancloud.com?ref=coolify.io) - US-based cloud infrastructure services
* [MassiveGrid](https://massivegrid.com?ref=coolify.io) - Enterprise cloud hosting solutions
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Brand.dev](https://brand.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [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
* [CubePath](https://cubepath.com/?ref=coolify.io) - Dedicated Servers & Instant Deploy
* [Darweb](https://darweb.nl/?ref=coolify.io) - Design. Develop. Deliver. Specialized in 3D CPQ Solutions
* [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
* [Gozunga](https://gozunga.com?ref=coolify.io) - Seriously Simple Cloud Infrastructure
* [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
* [JobsCollider](https://jobscollider.com/remote-jobs?ref=coolify.io) - 30,000+ remote jobs for developers
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PFGLabs](https://pfglabs.com?ref=coolify.io) - Build Real Projects with Golang
* [Ramnode](https://ramnode.com/?ref=coolify.io) - High Performance Cloud VPS Hosting
* [SaasyKit](https://saasykit.com?ref=coolify.io) - Complete SaaS starter kit for developers
* [SupaGuide](https://supa.guide?ref=coolify.io) - Your comprehensive guide to Supabase
* [Supadata AI](https://supadata.ai/?ref=coolify.io) - Scrape YouTube, web, and files. Get AI-ready, clean data
* [Syntax.fm](https://syntax.fm?ref=coolify.io) - Podcast for web developers
* [Tigris](https://www.tigrisdata.com?ref=coolify.io) - Modern developer data platform
* [Tolgee](https://tolgee.io?ref=coolify.io) - The open source localization platform
* [Ubicloud](https://www.ubicloud.com?ref=coolify.io) - Open source cloud infrastructure platform
## Small Sponsors

View file

@ -105,6 +105,8 @@ public function handle(StandaloneClickhouse $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -55,11 +55,11 @@ public function handle(StandaloneDragonfly $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -192,6 +192,8 @@ public function handle(StandaloneDragonfly $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -56,11 +56,11 @@ public function handle(StandaloneKeydb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -208,6 +208,8 @@ public function handle(StandaloneKeydb $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -57,11 +57,11 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -209,6 +209,8 @@ public function handle(StandaloneMariadb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
if ($this->database->enable_ssl) {

View file

@ -61,11 +61,11 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -260,6 +260,8 @@ public function handle(StandaloneMongodb $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, 'chown mongodb:mongodb /etc/mongo/certs/server.pem');

View file

@ -57,11 +57,11 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -210,6 +210,8 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {

View file

@ -62,11 +62,11 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -223,6 +223,8 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
$this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");

View file

@ -56,11 +56,11 @@ public function handle(StandaloneRedis $database)
$this->commands[] = "mkdir -p $this->configuration_dir/ssl";
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {
@ -205,6 +205,8 @@ public function handle(StandaloneRedis $database)
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop --timeout=10 $container_name 2>/dev/null || true";
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";

View file

@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
"docker stop --timeout=$timeout $containerName",
"docker rm -f $containerName",
], server: $server, throwError: false);
}

View file

@ -33,7 +33,13 @@ public function handle(Server $server, bool $forceRegenerate = false): string
// 1. Force regenerate is requested
// 2. Configuration file doesn't exist or is empty
if ($forceRegenerate || empty(trim($proxy_configuration ?? ''))) {
$proxy_configuration = str(generate_default_proxy_configuration($server))->trim()->value();
// Extract custom commands from existing config before regenerating
$custom_commands = [];
if (! empty(trim($proxy_configuration ?? ''))) {
$custom_commands = extractCustomProxyCommands($server, $proxy_configuration);
}
$proxy_configuration = str(generateDefaultProxyConfiguration($server, $custom_commands))->trim()->value();
}
if (empty($proxy_configuration)) {

View file

@ -19,6 +19,11 @@ public function handle(Server $server, bool $async = true, bool $force = false):
if ((is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop || $server->isBuildServer()) && $force === false) {
return 'OK';
}
$server->proxy->set('status', 'starting');
$server->save();
$server->refresh();
ProxyStatusChangedUI::dispatch($server->team_id);
$commands = collect([]);
$proxy_path = $server->proxyPath();
$configuration = GetProxyConfiguration::run($server);
@ -64,14 +69,12 @@ public function handle(Server $server, bool $async = true, bool $force = false):
]);
$commands = $commands->merge(connectProxyToNetworks($server));
}
$server->proxy->set('status', 'starting');
$server->save();
ProxyStatusChangedUI::dispatch($server->team_id);
if ($async) {
return remote_process($commands, $server, callEventOnFinish: 'ProxyStatusChanged', callEventData: $server->id);
} else {
instant_remote_process($commands, $server);
$server->proxy->set('type', $proxyType);
$server->save();
ProxyStatusChanged::dispatch($server->id);

View file

@ -2,16 +2,102 @@
namespace App\Actions\Server;
use App\Models\CloudProviderToken;
use App\Models\Server;
use App\Models\Team;
use App\Notifications\Server\HetznerDeletionFailed;
use App\Services\HetznerService;
use Lorisleiva\Actions\Concerns\AsAction;
class DeleteServer
{
use AsAction;
public function handle(Server $server)
public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null)
{
StopSentinel::run($server);
$server->forceDelete();
$server = Server::withTrashed()->find($serverId);
// Delete from Hetzner even if server is already gone from Coolify
if ($deleteFromHetzner && ($hetznerServerId || ($server && $server->hetzner_server_id))) {
$this->deleteFromHetznerById(
$hetznerServerId ?? $server->hetzner_server_id,
$cloudProviderTokenId ?? $server->cloud_provider_token_id,
$teamId ?? $server->team_id
);
}
ray($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion');
// If server is already deleted from Coolify, skip this part
if (! $server) {
return; // Server already force deleted from Coolify
}
ray('force deleting server from Coolify', ['server_id' => $server->id]);
try {
$server->forceDelete();
} 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', [
'error' => $e->getMessage(),
'server_id' => $server->id,
]);
}
}
private function deleteFromHetznerById(int $hetznerServerId, ?int $cloudProviderTokenId, int $teamId): void
{
try {
// Use the provided token, or fallback to first available team token
$token = null;
if ($cloudProviderTokenId) {
$token = CloudProviderToken::find($cloudProviderTokenId);
}
if (! $token) {
$token = CloudProviderToken::where('team_id', $teamId)
->where('provider', 'hetzner')
->first();
}
if (! $token) {
ray('No Hetzner token found for team, skipping Hetzner deletion', [
'team_id' => $teamId,
'hetzner_server_id' => $hetznerServerId,
]);
return;
}
$hetznerService = new HetznerService($token->token);
$hetznerService->deleteServer($hetznerServerId);
ray('Deleted server from Hetzner', [
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
} 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
logger()->error('Failed to delete server from Hetzner', [
'error' => $e->getMessage(),
'hetzner_server_id' => $hetznerServerId,
'team_id' => $teamId,
]);
// Notify the team about the failure
$team = Team::find($teamId);
$team?->notify(new HetznerDeletionFailed($hetznerServerId, $teamId, $e->getMessage()));
}
}
}

View file

@ -4,7 +4,6 @@
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use Lorisleiva\Actions\Concerns\AsAction;
@ -20,7 +19,7 @@ public function handle(Server $server)
throw new \Exception('Server OS type is not supported for automated installation. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://coolify.io/docs/installation#manually">documentation</a>.');
}
if (! SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->exists()) {
if (! $server->sslCertificates()->where('is_ca_certificate', true)->exists()) {
$serverCert = SslHelper::generateSslCertificate(
commonName: 'Coolify CA Certificate',
serverId: $server->id,

View file

@ -13,6 +13,7 @@
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
@ -58,6 +59,15 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in cleaning stucked resources: {$e->getMessage()}\n";
}
try {
$servers = Server::onlyTrashed()->get();
foreach ($servers as $server) {
echo "Force deleting stuck server: {$server->name}\n";
$server->forceDelete();
}
} catch (\Throwable $e) {
echo "Error in cleaning stuck servers: {$e->getMessage()}\n";
}
try {
$applicationsDeploymentQueue = ApplicationDeploymentQueue::get();
foreach ($applicationsDeploymentQueue as $applicationDeploymentQueue) {
@ -427,5 +437,18 @@ private function cleanup_stucked_resources()
} catch (\Throwable $e) {
echo "Error in ServiceDatabases: {$e->getMessage()}\n";
}
try {
$orphanedCerts = SslCertificate::whereNotIn('server_id', function ($query) {
$query->select('id')->from('servers');
})->get();
foreach ($orphanedCerts as $cert) {
echo "Deleting orphaned SSL certificate: {$cert->id} (server_id: {$cert->server_id})\n";
$cert->delete();
}
} catch (\Throwable $e) {
echo "Error in cleaning orphaned SSL certificates: {$e->getMessage()}\n";
}
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Console\Commands;
use App\Livewire\GlobalSearch;
use App\Models\Team;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class ClearGlobalSearchCache extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'search:clear {--team= : Clear cache for specific team ID} {--all : Clear cache for all teams}';
/**
* The console command description.
*/
protected $description = 'Clear the global search cache for testing or manual refresh';
/**
* Execute the console command.
*/
public function handle(): int
{
if ($this->option('all')) {
return $this->clearAllTeamsCache();
}
if ($teamId = $this->option('team')) {
return $this->clearTeamCache($teamId);
}
// If no options provided, clear cache for current user's team
if (! auth()->check()) {
$this->error('No authenticated user found. Use --team=ID or --all option.');
return Command::FAILURE;
}
$teamId = auth()->user()->currentTeam()->id;
return $this->clearTeamCache($teamId);
}
private function clearTeamCache(int $teamId): int
{
$team = Team::find($teamId);
if (! $team) {
$this->error("Team with ID {$teamId} not found.");
return Command::FAILURE;
}
GlobalSearch::clearTeamCache($teamId);
$this->info("✓ Cleared global search cache for team: {$team->name} (ID: {$teamId})");
return Command::SUCCESS;
}
private function clearAllTeamsCache(): int
{
$teams = Team::all();
if ($teams->isEmpty()) {
$this->warn('No teams found.');
return Command::SUCCESS;
}
$count = 0;
foreach ($teams as $team) {
GlobalSearch::clearTeamCache($team->id);
$count++;
}
$this->info("✓ Cleared global search cache for {$count} team(s)");
return Command::SUCCESS;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ServerValidated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ?int $teamId = null;
public ?string $serverUuid = null;
public function __construct(?int $teamId = null, ?string $serverUuid = null)
{
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
$teamId = auth()->user()->currentTeam()->id;
}
$this->teamId = $teamId;
$this->serverUuid = $serverUuid;
}
public function broadcastOn(): array
{
if (is_null($this->teamId)) {
return [];
}
return [
new PrivateChannel("team.{$this->teamId}"),
];
}
public function broadcastAs(): string
{
return 'ServerValidated';
}
public function broadcastWith(): array
{
return [
'teamId' => $this->teamId,
'serverUuid' => $this->serverUuid,
];
}
}

View file

@ -17,6 +17,7 @@
use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
@ -1512,9 +1513,33 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
if (! $request->docker_registry_image_tag) {
$request->offsetSet('docker_registry_image_tag', 'latest');
// Process docker image name and tag using DockerImageParser
$dockerImageName = $request->docker_registry_image_name;
$dockerImageTag = $request->docker_registry_image_tag;
// Build the full Docker image string for parsing
if ($dockerImageTag) {
$dockerImageString = $dockerImageName.':'.$dockerImageTag;
} else {
$dockerImageString = $dockerImageName;
}
// Parse using DockerImageParser to normalize the image reference
$parser = new DockerImageParser;
$parser->parse($dockerImageString);
// Get normalized image name and tag
$normalizedImageName = $parser->getFullImageNameWithoutTag();
// Append @sha256 to image name if using digest
if ($parser->isImageHash() && ! str_ends_with($normalizedImageName, '@sha256')) {
$normalizedImageName .= '@sha256';
}
// Set processed values back to request
$request->offsetSet('docker_registry_image_name', $normalizedImageName);
$request->offsetSet('docker_registry_image_tag', $parser->getTag());
$application = new Application;
removeUnnecessaryFieldsFromRequest($request);
@ -2469,7 +2494,7 @@ public function envs(Request $request)
)]
public function update_env_by_uuid(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2497,6 +2522,8 @@ public function update_env_by_uuid(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -2692,7 +2719,7 @@ public function create_bulk_envs(Request $request)
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal']);
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime']);
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
@ -2703,6 +2730,8 @@ public function create_bulk_envs(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
@ -2862,7 +2891,7 @@ public function create_bulk_envs(Request $request)
)]
public function create_env(Request $request)
{
$allowedFields = ['key', 'value', 'is_preview', 'is_literal'];
$allowedFields = ['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -2885,6 +2914,8 @@ public function create_env(Request $request)
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
'is_shown_once' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);

View file

@ -317,6 +317,10 @@ public function database_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_by_uuid(Request $request)
@ -593,6 +597,224 @@ public function update_by_uuid(Request $request)
]);
}
#[OA\Post(
summary: 'Create Backup',
description: 'Create a new scheduled backup configuration for a database',
path: '/databases/{uuid}/backups',
operationId: 'create-database-backup',
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',
)
),
],
requestBody: new OA\RequestBody(
description: 'Backup configuration data',
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['frequency'],
properties: [
'frequency' => ['type' => 'string', 'description' => 'Backup frequency (cron expression or: every_minute, hourly, daily, weekly, monthly, yearly)'],
'enabled' => ['type' => 'boolean', 'description' => 'Whether the backup is enabled', 'default' => true],
'save_s3' => ['type' => 'boolean', 'description' => 'Whether to save backups to S3', 'default' => false],
's3_storage_uuid' => ['type' => 'string', 'description' => 'S3 storage UUID (required if save_s3 is true)'],
'databases_to_backup' => ['type' => 'string', 'description' => 'Comma separated list of databases to backup'],
'dump_all' => ['type' => 'boolean', 'description' => 'Whether to dump all databases', 'default' => false],
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
],
),
)
),
responses: [
new OA\Response(
response: 201,
description: 'Backup configuration created successfully',
content: new OA\JsonContent(
type: 'object',
properties: [
'uuid' => ['type' => 'string', 'format' => 'uuid', 'example' => '550e8400-e29b-41d4-a716-446655440000'],
'message' => ['type' => 'string', 'example' => 'Backup configuration created successfully.'],
]
)
),
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_backup(Request $request)
{
$backupConfigFields = ['save_s3', 'enabled', 'dump_all', 'frequency', 'databases_to_backup', 'database_backup_retention_amount_locally', 'database_backup_retention_days_locally', 'database_backup_retention_max_storage_locally', 'database_backup_retention_amount_s3', 'database_backup_retention_days_s3', 'database_backup_retention_max_storage_s3', 's3_storage_uuid'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
// Validate incoming request is valid JSON
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
'frequency' => 'required|string',
'enabled' => 'boolean',
'save_s3' => 'boolean',
'dump_all' => 'boolean',
'backup_now' => 'boolean|nullable',
's3_storage_uuid' => 'string|exists:s3_storages,uuid|nullable',
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
if (! $request->uuid) {
return response()->json(['message' => 'UUID is required.'], 404);
}
$uuid = $request->uuid;
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
if (! $database) {
return response()->json(['message' => 'Database not found.'], 404);
}
$this->authorize('manageBackups', $database);
// Validate frequency is a valid cron expression
$isValid = validate_cron_expression($request->frequency);
if (! $isValid) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['frequency' => ['Invalid cron expression or frequency format.']],
], 422);
}
// Validate S3 storage if save_s3 is true
if ($request->boolean('save_s3') && ! $request->filled('s3_storage_uuid')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The s3_storage_uuid field is required when save_s3 is true.']],
], 422);
}
if ($request->filled('s3_storage_uuid')) {
$existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
}
// Check for extra fields
$extraFields = array_diff(array_keys($request->all()), $backupConfigFields, ['backup_now']);
if (! 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);
}
$backupData = $request->only($backupConfigFields);
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['s3_storage_uuid' => ['The selected S3 storage is invalid for this team.']],
], 422);
}
unset($backupData['s3_storage_uuid']);
}
// Set default databases_to_backup based on database type if not provided
if (! isset($backupData['databases_to_backup']) || empty($backupData['databases_to_backup'])) {
if ($database->type() === 'standalone-postgresql') {
$backupData['databases_to_backup'] = $database->postgres_db;
} elseif ($database->type() === 'standalone-mysql') {
$backupData['databases_to_backup'] = $database->mysql_database;
} elseif ($database->type() === 'standalone-mariadb') {
$backupData['databases_to_backup'] = $database->mariadb_database;
}
}
// Add required fields
$backupData['database_id'] = $database->id;
$backupData['database_type'] = $database->getMorphClass();
$backupData['team_id'] = $teamId;
// Set defaults
if (! isset($backupData['enabled'])) {
$backupData['enabled'] = true;
}
$backupConfig = ScheduledDatabaseBackup::create($backupData);
// Trigger immediate backup if requested
if ($request->backup_now) {
dispatch(new DatabaseBackupJob($backupConfig));
}
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
], 201);
}
#[OA\Patch(
summary: 'Update',
description: 'Update a specific backup configuration for a given database, identified by its UUID and the backup ID',
@ -666,6 +888,10 @@ public function update_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_backup(Request $request)
@ -844,6 +1070,10 @@ public function update_backup(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_postgresql(Request $request)
@ -907,6 +1137,10 @@ public function create_database_postgresql(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_clickhouse(Request $request)
@ -969,6 +1203,10 @@ public function create_database_clickhouse(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_dragonfly(Request $request)
@ -1032,6 +1270,10 @@ public function create_database_dragonfly(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_redis(Request $request)
@ -1095,6 +1337,10 @@ public function create_database_redis(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_keydb(Request $request)
@ -1161,6 +1407,10 @@ public function create_database_keydb(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mariadb(Request $request)
@ -1227,6 +1477,10 @@ public function create_database_mariadb(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mysql(Request $request)
@ -1290,6 +1544,10 @@ public function create_database_mysql(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_database_mongodb(Request $request)
@ -1941,7 +2199,7 @@ public function delete_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration and all executions deleted.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration and all executions deleted.'),
]
)
),
@ -1951,7 +2209,7 @@ public function delete_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup configuration not found.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup configuration not found.'),
]
)
),
@ -2065,7 +2323,7 @@ public function delete_backup_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution deleted.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup execution deleted.'),
]
)
),
@ -2075,7 +2333,7 @@ public function delete_backup_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'message' => new OA\Schema(type: 'string', example: 'Backup execution not found.'),
new OA\Property(property: 'message', type: 'string', example: 'Backup execution not found.'),
]
)
),
@ -2171,17 +2429,18 @@ public function delete_execution_by_uuid(Request $request)
content: new OA\JsonContent(
type: 'object',
properties: [
'executions' => new OA\Schema(
new OA\Property(
property: 'executions',
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
'filename' => ['type' => 'string'],
'size' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
'message' => ['type' => 'string'],
'status' => ['type' => 'string'],
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'filename', type: 'string'),
new OA\Property(property: 'size', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string'),
new OA\Property(property: 'message', type: 'string'),
new OA\Property(property: 'status', type: 'string'),
]
)
),

View file

@ -131,6 +131,161 @@ public function deployment_by_uuid(Request $request)
return response()->json($this->removeSensitiveData($deployment));
}
#[OA\Post(
summary: 'Cancel',
description: 'Cancel a deployment by UUID.',
path: '/deployments/{uuid}/cancel',
operationId: 'cancel-deployment-by-uuid',
security: [
['bearerAuth' => []],
],
tags: ['Deployments'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Deployment UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Deployment cancelled successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Deployment cancelled successfully.'],
'deployment_uuid' => ['type' => 'string', 'example' => 'cm37r6cqj000008jm0veg5tkm'],
'status' => ['type' => 'string', 'example' => 'cancelled-by-user'],
]
)
),
]),
new OA\Response(
response: 400,
description: 'Deployment cannot be cancelled (already finished/failed/cancelled).',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Deployment cannot be cancelled. Current status: finished'],
]
)
),
]),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 403,
description: 'User doesn\'t have permission to cancel this deployment.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'You do not have permission to cancel this deployment.'],
]
)
),
]),
new OA\Response(
response: 404,
ref: '#/components/responses/404',
),
]
)]
public function cancel_deployment(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$uuid = $request->route('uuid');
if (! $uuid) {
return response()->json(['message' => 'UUID is required.'], 400);
}
// Find the deployment by UUID
$deployment = ApplicationDeploymentQueue::where('deployment_uuid', $uuid)->first();
if (! $deployment) {
return response()->json(['message' => 'Deployment not found.'], 404);
}
// Check if the deployment belongs to the user's team
$servers = Server::whereTeamId($teamId)->pluck('id');
if (! $servers->contains($deployment->server_id)) {
return response()->json(['message' => 'You do not have permission to cancel this deployment.'], 403);
}
// Check if deployment can be cancelled (must be queued or in_progress)
$cancellableStatuses = [
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
];
if (! in_array($deployment->status, $cancellableStatuses)) {
return response()->json([
'message' => "Deployment cannot be cancelled. Current status: {$deployment->status}",
], 400);
}
// Perform the cancellation
try {
$deployment_uuid = $deployment->deployment_uuid;
$kill_command = "docker rm -f {$deployment_uuid}";
$build_server_id = $deployment->build_server_id ?? $deployment->server_id;
// Mark deployment as cancelled
$deployment->update([
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
]);
// Get the server
$server = Server::find($build_server_id);
if ($server) {
// Add cancellation log entry
$deployment->addLogEntry('Deployment cancelled by user via API.', 'stderr');
// Check if container exists and kill it
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
$containerExists = instant_remote_process([$checkCommand], $server);
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
instant_remote_process([$kill_command], $server);
$deployment->addLogEntry('Deployment container stopped.');
} else {
$deployment->addLogEntry('Deployment container not yet started. Will be cancelled when job checks status.');
}
// Kill running process if process ID exists
if ($deployment->current_process_id) {
try {
$processKillCommand = "kill -9 {$deployment->current_process_id}";
instant_remote_process([$processKillCommand], $server);
} catch (\Throwable $e) {
// Process might already be gone
}
}
}
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
'status' => $deployment->status,
]);
} catch (\Throwable $e) {
return response()->json([
'message' => 'Failed to cancel deployment: '.$e->getMessage(),
], 500);
}
}
#[OA\Get(
summary: 'Deploy',
description: 'Deploy by tag or uuid. `Post` request also accepted with `uuid` and `tag` json body.',

View file

@ -12,6 +12,88 @@
class GithubController extends Controller
{
private function removeSensitiveData($githubApp)
{
$githubApp->makeHidden([
'client_secret',
'webhook_secret',
]);
return serializeApiResponse($githubApp);
}
#[OA\Get(
summary: 'List',
description: 'List all GitHub apps.',
path: '/github-apps',
operationId: 'list-github-apps',
security: [
['bearerAuth' => []],
],
tags: ['GitHub Apps'],
responses: [
new OA\Response(
response: 200,
description: 'List of GitHub apps.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'organization' => ['type' => 'string', 'nullable' => true],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'app_id' => ['type' => 'integer'],
'installation_id' => ['type' => 'integer'],
'client_id' => ['type' => 'string'],
'private_key_id' => ['type' => 'integer'],
'is_system_wide' => ['type' => 'boolean'],
'is_public' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'type' => ['type' => 'string'],
]
)
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function list_github_apps(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$githubApps = GithubApp::where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
})->get();
$githubApps = $githubApps->map(function ($app) {
return $this->removeSensitiveData($app);
});
return response()->json($githubApps);
}
#[OA\Post(
summary: 'Create GitHub App',
description: 'Create a new GitHub app.',
@ -219,7 +301,8 @@ public function create_github_app(Request $request)
schema: new OA\Schema(
type: 'object',
properties: [
'repositories' => new OA\Schema(
new OA\Property(
property: 'repositories',
type: 'array',
items: new OA\Items(type: 'object')
),
@ -335,7 +418,8 @@ public function load_repositories($github_app_id)
schema: new OA\Schema(
type: 'object',
properties: [
'branches' => new OA\Schema(
new OA\Property(
property: 'branches',
type: 'array',
items: new OA\Items(type: 'object')
),
@ -457,7 +541,7 @@ public function load_branches($github_app_id, $owner, $repo)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitHub app not found'),
new OA\Response(response: 422, description: 'Validation error'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update_github_app(Request $request, $github_app_id)

View file

@ -40,6 +40,27 @@
new OA\Property(property: 'message', type: 'string', example: 'Resource not found.'),
]
)),
new OA\Response(
response: 422,
description: 'Validation error.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Validation error.'),
new OA\Property(
property: 'errors',
type: 'object',
additionalProperties: new OA\AdditionalProperties(
type: 'array',
items: new OA\Items(type: 'string')
),
example: [
'name' => ['The name field is required.'],
'api_url' => ['The api url field is required.', 'The api url format is invalid.'],
]
),
]
)),
],
)]
class OpenApi

View file

@ -21,8 +21,9 @@ class OtherController extends Controller
new OA\Response(
response: 200,
description: 'Returns the version of the application',
content: new OA\JsonContent(
type: 'string',
content: new OA\MediaType(
mediaType: 'text/html',
schema: new OA\Schema(type: 'string'),
example: 'v4.0.0',
)),
new OA\Response(
@ -166,8 +167,9 @@ public function feedback(Request $request)
new OA\Response(
response: 200,
description: 'Healthcheck endpoint.',
content: new OA\JsonContent(
type: 'string',
content: new OA\MediaType(
mediaType: 'text/html',
schema: new OA\Schema(type: 'string'),
example: 'OK',
)),
new OA\Response(

View file

@ -134,6 +134,10 @@ public function project_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function environment_details(Request $request)
@ -214,6 +218,10 @@ public function environment_details(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_project(Request $request)
@ -324,6 +332,10 @@ public function create_project(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_project(Request $request)
@ -425,6 +437,10 @@ public function update_project(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_project(Request $request)
@ -487,6 +503,10 @@ public function delete_project(Request $request)
response: 404,
description: 'Project not found.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function get_environments(Request $request)
@ -566,6 +586,10 @@ public function get_environments(Request $request)
response: 409,
description: 'Environment with this name already exists.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_environment(Request $request)
@ -663,6 +687,10 @@ public function create_environment(Request $request)
response: 404,
description: 'Project or environment not found.',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_environment(Request $request)

View file

@ -163,6 +163,10 @@ public function key_by_uuid(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_key(Request $request)
@ -282,6 +286,10 @@ public function create_key(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_key(Request $request)

View file

@ -447,6 +447,10 @@ public function domains_by_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_server(Request $request)
@ -604,6 +608,10 @@ public function create_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_server(Request $request)
@ -722,6 +730,10 @@ public function update_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function delete_server(Request $request)
@ -746,7 +758,13 @@ public function delete_server(Request $request)
return response()->json(['message' => 'Local server cannot be deleted.'], 400);
}
$server->delete();
DeleteServer::dispatch($server);
DeleteServer::dispatch(
$server->id,
false, // Don't delete from Hetzner via API
$server->hetzner_server_id,
$server->cloud_provider_token_id,
$server->team_id
);
return response()->json(['message' => 'Server deleted.']);
}
@ -790,6 +808,10 @@ public function delete_server(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function validate_server(Request $request)

View file

@ -235,6 +235,10 @@ public function services(Request $request)
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_service(Request $request)
@ -324,9 +328,23 @@ public function create_service(Request $request)
});
}
if ($oneClickService) {
$service_payload = [
$dockerComposeRaw = base64_decode($oneClickService);
// Validate for command injection BEFORE creating service
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$servicePayload = [
'name' => "$oneClickServiceName-".str()->random(10),
'docker_compose_raw' => base64_decode($oneClickService),
'docker_compose_raw' => $dockerComposeRaw,
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
'server_id' => $server->id,
@ -334,9 +352,9 @@ public function create_service(Request $request)
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
data_set($service_payload, 'connect_to_docker_network', true);
data_set($servicePayload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);
$service = Service::create($servicePayload);
$service->name = "$oneClickServiceName-".$service->uuid;
$service->save();
if ($oneClickDotEnvs?->count() > 0) {
@ -458,6 +476,18 @@ public function create_service(Request $request)
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$connectToDockerNetwork = $request->connect_to_docker_network ?? false;
$instantDeploy = $request->instant_deploy ?? false;
@ -704,6 +734,10 @@ public function delete_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_by_uuid(Request $request)
@ -769,6 +803,19 @@ public function update_by_uuid(Request $request)
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
try {
validateDockerComposeForInjection($dockerComposeRaw);
} catch (\Exception $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => $e->getMessage(),
],
], 422);
}
$service->docker_compose_raw = $dockerComposeRaw;
}
@ -954,6 +1001,10 @@ public function envs(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function update_env_by_uuid(Request $request)
@ -1075,6 +1126,10 @@ public function update_env_by_uuid(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_bulk_envs(Request $request)
@ -1191,6 +1246,10 @@ public function create_bulk_envs(Request $request)
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_env(Request $request)

View file

@ -14,7 +14,7 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
// \App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,

View file

@ -2,7 +2,10 @@
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Illuminate\Http\Middleware\TrustHosts as Middleware;
use Illuminate\Support\Facades\Cache;
use Spatie\Url\Url;
class TrustHosts extends Middleware
{
@ -13,8 +16,37 @@ class TrustHosts extends Middleware
*/
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),
];
$trustedHosts = [];
// Trust the configured FQDN from InstanceSettings (cached to avoid DB query on every request)
// Use empty string as sentinel value instead of null so negative results are cached
$fqdnHost = Cache::remember('instance_settings_fqdn_host', 300, function () {
try {
$settings = InstanceSettings::get();
if ($settings && $settings->fqdn) {
$url = Url::fromString($settings->fqdn);
$host = $url->getHost();
return $host ?: '';
}
} catch (\Exception $e) {
// If instance settings table doesn't exist yet (during installation),
// return empty string (sentinel) so this result is cached
}
return '';
});
// Convert sentinel value back to null for consumption
$fqdnHost = $fqdnHost !== '' ? $fqdnHost : null;
if ($fqdnHost) {
$trustedHosts[] = $fqdnHost;
}
// Trust all subdomains of APP_URL as fallback
$trustedHosts[] = $this->allSubdomainsOfApplicationUrl();
return array_filter($trustedHosts);
}
}

File diff suppressed because it is too large Load diff

View file

@ -35,21 +35,25 @@ public function handle()
if ($this->application->is_public_repository()) {
return;
}
$serviceName = $this->application->name;
if ($this->status === ProcessStatus::CLOSED) {
$this->delete_comment();
return;
} elseif ($this->status === ProcessStatus::IN_PROGRESS) {
$this->body = "The preview deployment is in progress. 🟡\n\n";
} elseif ($this->status === ProcessStatus::FINISHED) {
$this->body = "The preview deployment is ready. 🟢\n\n";
if ($this->preview->fqdn) {
$this->body .= "[Open Preview]({$this->preview->fqdn}) | ";
}
} elseif ($this->status === ProcessStatus::ERROR) {
$this->body = "The preview deployment failed. 🔴\n\n";
}
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/{$this->application->environment->name}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
match ($this->status) {
ProcessStatus::QUEUED => $this->body = "The preview deployment for **{$serviceName}** is queued. ⏳\n\n",
ProcessStatus::IN_PROGRESS => $this->body = "The preview deployment for **{$serviceName}** is in progress. 🟡\n\n",
ProcessStatus::FINISHED => $this->body = "The preview deployment for **{$serviceName}** is ready. 🟢\n\n".($this->preview->fqdn ? "[Open Preview]({$this->preview->fqdn}) | " : ''),
ProcessStatus::ERROR => $this->body = "The preview deployment for **{$serviceName}** failed. 🔴\n\n",
ProcessStatus::KILLED => $this->body = "The preview deployment for **{$serviceName}** was killed. ⚫\n\n",
ProcessStatus::CANCELLED => $this->body = "The preview deployment for **{$serviceName}** was cancelled. 🚫\n\n",
ProcessStatus::CLOSED => '', // Already handled above, but included for completeness
};
$this->build_logs_url = base_url()."/project/{$this->application->environment->project->uuid}/environment/{$this->application->environment->uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}";
$this->body .= '[Open Build Logs]('.$this->build_logs_url.")\n\n\n";
$this->body .= 'Last updated at: '.now()->toDateTimeString().' CET';

View file

@ -15,6 +15,7 @@
use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@ -68,7 +69,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
public $timeout = 3600;
public string $backup_log_uuid;
public ?string $backup_log_uuid = null;
public function __construct(public ScheduledDatabaseBackup $backup)
{
@ -298,6 +299,10 @@ public function handle(): void
} while ($exists);
$size = 0;
$localBackupSucceeded = false;
$s3UploadError = null;
// Step 1: Create local backup
try {
if (str($databaseType)->contains('postgres')) {
$this->backup_file = "/pg-dump-$database-".Carbon::now()->timestamp.'.dmp';
@ -310,6 +315,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_postgresql($database);
} elseif (str($databaseType)->contains('mongo')) {
@ -330,6 +336,7 @@ public function handle(): void
'database_name' => $databaseName,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mongodb($database);
} elseif (str($databaseType)->contains('mysql')) {
@ -343,6 +350,7 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mysql($database);
} elseif (str($databaseType)->contains('mariadb')) {
@ -356,56 +364,77 @@ public function handle(): void
'database_name' => $database,
'filename' => $this->backup_location,
'scheduled_database_backup_id' => $this->backup->id,
'local_storage_deleted' => false,
]);
$this->backup_standalone_mariadb($database);
} else {
throw new \Exception('Unsupported database type');
}
$size = $this->calculate_size();
if ($this->backup->save_s3) {
// Verify local backup succeeded
if ($size > 0) {
$localBackupSucceeded = true;
} else {
throw new \Exception('Local backup file is empty or was not created');
}
} catch (\Throwable $e) {
// Local backup failed
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
's3_uploaded' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
continue;
}
// Step 2: Upload to S3 if enabled (independent of local backup)
$localStorageDeleted = false;
if ($this->backup->save_s3 && $localBackupSucceeded) {
try {
$this->upload_to_s3();
// If local backup is disabled, delete the local file immediately after S3 upload
if ($this->backup->disable_local_backup) {
deleteBackupsLocally($this->backup_location, $this->server);
$localStorageDeleted = true;
}
} catch (\Throwable $e) {
// S3 upload failed but local backup succeeded
$s3UploadError = $e->getMessage();
}
}
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Step 3: Update status and send notifications based on results
if ($localBackupSucceeded) {
$message = $this->backup_output;
if ($s3UploadError) {
$message = $message
? $message."\n\nWarning: S3 upload failed: ".$s3UploadError
: 'Warning: S3 upload failed: '.$s3UploadError;
}
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output,
'message' => $message,
'size' => $size,
's3_uploaded' => $this->backup->save_s3 ? $this->s3_uploaded : null,
'local_storage_deleted' => $localStorageDeleted,
]);
} catch (\Throwable $e) {
// Check if backup actually failed or if it's just a post-backup issue
$actualBackupFailed = ! $this->s3_uploaded && $this->backup->save_s3;
if ($actualBackupFailed || $size === 0) {
// Real backup failure
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'failed',
'message' => $this->error_output ?? $this->backup_output ?? $e->getMessage(),
'size' => $size,
'filename' => null,
]);
}
$this->team?->notify(new BackupFailed($this->backup, $this->database, $this->error_output ?? $this->backup_output ?? $e->getMessage(), $database));
// Send appropriate notification
if ($s3UploadError) {
$this->team->notify(new BackupSuccessWithS3Warning($this->backup, $this->database, $database, $s3UploadError));
} else {
// Backup succeeded but post-processing failed (cleanup, notification, etc.)
if ($this->backup_log) {
$this->backup_log->update([
'status' => 'success',
'message' => $this->backup_output ? $this->backup_output."\nWarning: Post-backup cleanup encountered an issue: ".$e->getMessage() : 'Warning: '.$e->getMessage(),
'size' => $size,
]);
}
// Send success notification since the backup itself succeeded
$this->team->notify(new BackupSuccess($this->backup, $this->database, $database));
// Log the post-backup issue
ray('Post-backup operation failed but backup was successful: '.$e->getMessage());
}
}
}
@ -591,24 +620,24 @@ private function upload_to_s3(): void
$fullImageName = $this->getFullImageName();
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup->uuid}"], $this->server, false);
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
if (filled($containerExists)) {
instant_remote_process(["docker rm -f backup-of-{$this->backup->uuid}"], $this->server, false);
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
}
if (isDev()) {
if ($this->database->name === 'coolify-db') {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/coolify/coolify-db-'.$this->server->ip.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
} else {
$backup_location_from = '/var/lib/docker/volumes/coolify_dev_backups_data/_data/databases/'.str($this->team->name)->slug().'-'.$this->team->id.'/'.$this->directory_name.$this->backup_file;
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $backup_location_from:$this->backup_location:ro {$fullImageName}";
}
} else {
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup->uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
}
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup->uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
instant_remote_process($commands, $this->server);
$this->s3_uploaded = true;
@ -617,7 +646,7 @@ private function upload_to_s3(): void
$this->add_to_error_output($e->getMessage());
throw $e;
} finally {
$command = "docker rm -f backup-of-{$this->backup->uuid}";
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
instant_remote_process([$command], $this->server);
}
}

View file

@ -45,7 +45,7 @@ public function handle()
$query->cursor()->each(function ($certificate) use ($regenerated) {
try {
$caCert = SslCertificate::where('server_id', $certificate->server_id)
$caCert = $certificate->server->sslCertificates()
->where('is_ca_certificate', true)
->first();

View file

@ -0,0 +1,60 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* The number of times the job may be attempted.
*
* @var int
*/
public $tries = 5;
public $backoff = 10;
/**
* The maximum number of unhandled exceptions to allow before failing.
*/
public int $maxExceptions = 5;
public function __construct(
public array $payload,
public string $webhookUrl
) {
$this->onQueue('high');
}
/**
* Execute the job.
*/
public function handle(): void
{
if (isDev()) {
ray('Sending webhook notification', [
'url' => $this->webhookUrl,
'payload' => $this->payload,
]);
}
$response = Http::post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
'status' => $response->status(),
'body' => $response->body(),
'successful' => $response->successful(),
]);
}
}
}

View file

@ -54,6 +54,11 @@ public function handle()
return;
}
// Check Hetzner server status if applicable
if ($this->server->hetzner_server_id && $this->server->cloudProviderToken) {
$this->checkHetznerStatus();
}
// Temporarily disable mux if requested
if ($this->disableMux) {
$this->disableSshMux();
@ -86,6 +91,11 @@ public function handle()
]);
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
'error' => $e->getMessage(),
'server_id' => $this->server->id,
]);
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
@ -95,6 +105,30 @@ public function handle()
}
}
private function checkHetznerStatus(): void
{
try {
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$status = $serverData['status'] ?? null;
} catch (\Throwable $e) {
Log::debug('ServerConnectionCheck: Hetzner status check failed', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
]);
}
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
{
try {

View file

@ -0,0 +1,162 @@
<?php
namespace App\Jobs;
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Events\ServerReachabilityChanged;
use App\Events\ServerValidated;
use App\Models\Server;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ValidateAndInstallServerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $timeout = 600; // 10 minutes
public int $maxTries = 3;
public function __construct(
public Server $server,
public int $numberOfTries = 0
) {
$this->onQueue('high');
}
public function handle(): void
{
try {
// Mark validation as in progress
$this->server->update(['is_validating' => true]);
Log::info('ValidateAndInstallServer: Starting validation', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
'attempt' => $this->numberOfTries + 1,
]);
// Validate connection
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if (! $uptime) {
$errorMessage = 'Server is not reachable. Please validate your configuration and connection.<br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error;
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Server not reachable', [
'server_id' => $this->server->id,
'error' => $error,
]);
return;
}
// Validate OS
$supportedOsType = $this->server->validateOS();
if (! $supportedOsType) {
$errorMessage = 'Server OS type is not supported. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: OS not supported', [
'server_id' => $this->server->id,
]);
return;
}
// Check if Docker is installed
$dockerInstalled = $this->server->validateDockerEngine();
$dockerComposeInstalled = $this->server->validateDockerCompose();
if (! $dockerInstalled || ! $dockerComposeInstalled) {
// Try to install Docker
if ($this->numberOfTries >= $this->maxTries) {
$errorMessage = 'Docker Engine could not be installed after '.$this->maxTries.' attempts. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Docker installation failed after max tries', [
'server_id' => $this->server->id,
'attempts' => $this->numberOfTries,
]);
return;
}
Log::info('ValidateAndInstallServer: Installing Docker', [
'server_id' => $this->server->id,
'attempt' => $this->numberOfTries + 1,
]);
// Install Docker
$this->server->installDocker();
// Retry validation after installation
self::dispatch($this->server, $this->numberOfTries + 1)->delay(now()->addSeconds(30));
return;
}
// Validate Docker version
$dockerVersion = $this->server->validateDockerEngineVersion();
if (! $dockerVersion) {
$requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: <a target="_blank" class="underline" href="https://docs.docker.com/engine/install/#server">documentation</a>.';
$this->server->update([
'validation_logs' => $errorMessage,
'is_validating' => false,
]);
Log::error('ValidateAndInstallServer: Docker version not sufficient', [
'server_id' => $this->server->id,
]);
return;
}
// Validation successful!
Log::info('ValidateAndInstallServer: Validation successful', [
'server_id' => $this->server->id,
'server_name' => $this->server->name,
]);
// Start proxy if needed
if (! $this->server->isBuildServer()) {
$proxyShouldRun = CheckProxy::run($this->server, true);
if ($proxyShouldRun) {
StartProxy::dispatch($this->server);
}
}
// Mark validation as complete
$this->server->update(['is_validating' => false]);
// Refresh server to get latest state
$this->server->refresh();
// Broadcast events to update UI
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
ServerReachabilityChanged::dispatch($this->server);
} catch (\Throwable $e) {
Log::error('ValidateAndInstallServer: Exception occurred', [
'server_id' => $this->server->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->server->update([
'validation_logs' => 'An error occurred during validation: '.$e->getMessage(),
'is_validating' => false,
]);
}
}
}

View file

@ -16,14 +16,18 @@ class Index extends Component
{
protected $listeners = ['refreshBoardingIndex' => 'validateServer'];
#[\Livewire\Attributes\Url(as: 'step', history: true)]
public string $currentState = 'welcome';
#[\Livewire\Attributes\Url(keep: true)]
public ?string $selectedServerType = null;
public ?Collection $privateKeys = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedExistingPrivateKey = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?string $privateKeyType = null;
public ?string $privateKey = null;
@ -38,6 +42,7 @@ class Index extends Component
public ?Collection $servers = null;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedExistingServer = null;
public ?string $remoteServerName = null;
@ -58,6 +63,7 @@ class Index extends Component
public Collection $projects;
#[\Livewire\Attributes\Url(keep: true)]
public ?int $selectedProject = null;
public ?Project $createdProject = null;
@ -79,17 +85,68 @@ public function mount()
$this->minDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.');
$this->privateKeyName = generate_random_name();
$this->remoteServerName = generate_random_name();
if (isDev()) {
$this->privateKey = '-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk
hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA
AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV
uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==
-----END OPENSSH PRIVATE KEY-----';
$this->privateKeyDescription = 'Created by Coolify';
$this->remoteServerDescription = 'Created by Coolify';
$this->remoteServerHost = 'coolify-testing-host';
// Initialize collections to avoid null errors
if ($this->privateKeys === null) {
$this->privateKeys = collect();
}
if ($this->servers === null) {
$this->servers = collect();
}
if (! isset($this->projects)) {
$this->projects = collect();
}
// Restore state when coming from URL with query params
if ($this->selectedServerType === 'localhost' && $this->selectedExistingServer === 0) {
$this->createdServer = Server::find(0);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
}
}
if ($this->selectedServerType === 'remote') {
if ($this->privateKeys->isEmpty()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
if ($this->servers->isEmpty()) {
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
if ($this->selectedExistingServer) {
$this->createdServer = Server::find($this->selectedExistingServer);
if ($this->createdServer) {
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
}
}
if ($this->selectedExistingPrivateKey) {
$this->createdPrivateKey = PrivateKey::where('team_id', currentTeam()->id)
->where('id', $this->selectedExistingPrivateKey)
->first();
if ($this->createdPrivateKey) {
$this->privateKey = $this->createdPrivateKey->private_key;
$this->publicKey = $this->createdPrivateKey->getPublicKey();
}
}
// Auto-regenerate key pair for "Generate with Coolify" mode on page refresh
if ($this->privateKeyType === 'create' && empty($this->privateKey)) {
$this->createNewPrivateKey();
}
}
if ($this->selectedProject) {
$this->createdProject = Project::find($this->selectedProject);
if (! $this->createdProject) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
}
// Load projects when on create-project state (for page refresh)
if ($this->currentState === 'create-project' && $this->projects->isEmpty()) {
$this->projects = Project::ownedByCurrentTeam(['name'])->get();
}
}
@ -129,41 +186,16 @@ public function setServerType(string $type)
return $this->validateServer('localhost');
} elseif ($this->selectedServerType === 'remote') {
if (isDev()) {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->get();
} else {
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
}
$this->privateKeys = PrivateKey::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
// Auto-select first key if available for better UX
if ($this->privateKeys->count() > 0) {
$this->selectedExistingPrivateKey = $this->privateKeys->first()->id;
}
$this->servers = Server::ownedByCurrentTeam(['name'])->where('id', '!=', 0)->get();
if ($this->servers->count() > 0) {
$this->selectedExistingServer = $this->servers->first()->id;
$this->updateServerDetails();
$this->currentState = 'select-existing-server';
return;
}
// Onboarding always creates new servers, skip existing server selection
$this->currentState = 'private-key';
}
}
public function selectExistingServer()
{
$this->createdServer = Server::find($this->selectedExistingServer);
if (! $this->createdServer) {
$this->dispatch('error', 'Server is not found.');
$this->currentState = 'private-key';
return;
}
$this->selectedExistingPrivateKey = $this->createdServer->privateKey->id;
$this->serverPublicKey = $this->createdServer->privateKey->getPublicKey();
$this->updateServerDetails();
$this->currentState = 'validate-server';
}
private function updateServerDetails()
{
if ($this->createdServer) {
@ -181,7 +213,7 @@ public function getProxyType()
public function selectExistingPrivateKey()
{
if (is_null($this->selectedExistingPrivateKey)) {
$this->restartBoarding();
$this->dispatch('error', 'Please select a private key.');
return;
}
@ -202,6 +234,9 @@ public function setPrivateKey(string $type)
$this->privateKeyType = $type;
if ($type === 'create') {
$this->createNewPrivateKey();
} else {
$this->privateKey = null;
$this->publicKey = null;
}
$this->currentState = 'create-private-key';
}

View file

@ -10,7 +10,7 @@
class Dashboard extends Component
{
public $projects = [];
public Collection $projects;
public Collection $servers;
@ -23,11 +23,6 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get();
}
public function navigateToProject($projectUuid)
{
return $this->redirect(collect($this->projects)->firstWhere('uuid', $projectUuid)->navigateTo(), navigate: false);
}
public function render()
{
return view('livewire.dashboard');

View file

@ -16,8 +16,10 @@ public function deployments()
{
$servers = Server::ownedByCurrentTeam()->get();
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued'])
return ApplicationDeploymentQueue::with(['application.environment.project'])
->whereIn('status', ['in_progress', 'queued'])
->whereIn('server_id', $servers->pluck('id'))
->orderBy('id')
->get([
'id',
'application_id',
@ -27,8 +29,7 @@ public function deployments()
'server_name',
'server_id',
'status',
])
->sortBy('id');
]);
}
#[Computed]

File diff suppressed because it is too large Load diff

View file

@ -25,6 +25,7 @@ public function __construct(
public bool $readonly,
public bool $allowTab,
public bool $spellcheck,
public bool $autofocus = false,
public ?string $helper,
public bool $realtimeValidation,
public bool $allowToPeak,

View file

@ -0,0 +1,196 @@
<?php
namespace App\Livewire\Notifications;
use App\Models\Team;
use App\Models\WebhookNotificationSettings;
use App\Notifications\Test;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Webhook extends Component
{
use AuthorizesRequests;
public Team $team;
public WebhookNotificationSettings $settings;
#[Validate(['boolean'])]
public bool $webhookEnabled = false;
#[Validate(['url', 'nullable'])]
public ?string $webhookUrl = null;
#[Validate(['boolean'])]
public bool $deploymentSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $deploymentFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $statusChangeWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $backupSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $backupFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $scheduledTaskSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $scheduledTaskFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $dockerCleanupSuccessWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $dockerCleanupFailureWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverDiskUsageWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverReachableWebhookNotifications = false;
#[Validate(['boolean'])]
public bool $serverUnreachableWebhookNotifications = true;
#[Validate(['boolean'])]
public bool $serverPatchWebhookNotifications = false;
public function mount()
{
try {
$this->team = auth()->user()->currentTeam();
$this->settings = $this->team->webhookNotificationSettings;
$this->authorize('view', $this->settings);
$this->syncData();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->authorize('update', $this->settings);
$this->settings->webhook_enabled = $this->webhookEnabled;
$this->settings->webhook_url = $this->webhookUrl;
$this->settings->deployment_success_webhook_notifications = $this->deploymentSuccessWebhookNotifications;
$this->settings->deployment_failure_webhook_notifications = $this->deploymentFailureWebhookNotifications;
$this->settings->status_change_webhook_notifications = $this->statusChangeWebhookNotifications;
$this->settings->backup_success_webhook_notifications = $this->backupSuccessWebhookNotifications;
$this->settings->backup_failure_webhook_notifications = $this->backupFailureWebhookNotifications;
$this->settings->scheduled_task_success_webhook_notifications = $this->scheduledTaskSuccessWebhookNotifications;
$this->settings->scheduled_task_failure_webhook_notifications = $this->scheduledTaskFailureWebhookNotifications;
$this->settings->docker_cleanup_success_webhook_notifications = $this->dockerCleanupSuccessWebhookNotifications;
$this->settings->docker_cleanup_failure_webhook_notifications = $this->dockerCleanupFailureWebhookNotifications;
$this->settings->server_disk_usage_webhook_notifications = $this->serverDiskUsageWebhookNotifications;
$this->settings->server_reachable_webhook_notifications = $this->serverReachableWebhookNotifications;
$this->settings->server_unreachable_webhook_notifications = $this->serverUnreachableWebhookNotifications;
$this->settings->server_patch_webhook_notifications = $this->serverPatchWebhookNotifications;
$this->settings->save();
refreshSession();
} else {
$this->webhookEnabled = $this->settings->webhook_enabled;
$this->webhookUrl = $this->settings->webhook_url;
$this->deploymentSuccessWebhookNotifications = $this->settings->deployment_success_webhook_notifications;
$this->deploymentFailureWebhookNotifications = $this->settings->deployment_failure_webhook_notifications;
$this->statusChangeWebhookNotifications = $this->settings->status_change_webhook_notifications;
$this->backupSuccessWebhookNotifications = $this->settings->backup_success_webhook_notifications;
$this->backupFailureWebhookNotifications = $this->settings->backup_failure_webhook_notifications;
$this->scheduledTaskSuccessWebhookNotifications = $this->settings->scheduled_task_success_webhook_notifications;
$this->scheduledTaskFailureWebhookNotifications = $this->settings->scheduled_task_failure_webhook_notifications;
$this->dockerCleanupSuccessWebhookNotifications = $this->settings->docker_cleanup_success_webhook_notifications;
$this->dockerCleanupFailureWebhookNotifications = $this->settings->docker_cleanup_failure_webhook_notifications;
$this->serverDiskUsageWebhookNotifications = $this->settings->server_disk_usage_webhook_notifications;
$this->serverReachableWebhookNotifications = $this->settings->server_reachable_webhook_notifications;
$this->serverUnreachableWebhookNotifications = $this->settings->server_unreachable_webhook_notifications;
$this->serverPatchWebhookNotifications = $this->settings->server_patch_webhook_notifications;
}
}
public function instantSaveWebhookEnabled()
{
try {
$original = $this->webhookEnabled;
$this->validate([
'webhookUrl' => 'required',
], [
'webhookUrl.required' => 'Webhook URL is required.',
]);
$this->saveModel();
} catch (\Throwable $e) {
$this->webhookEnabled = $original;
return handleError($e, $this);
}
}
public function instantSave()
{
try {
$this->syncData(true);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {
$this->resetErrorBag();
$this->syncData(true);
$this->saveModel();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function saveModel()
{
$this->syncData(true);
refreshSession();
if (isDev()) {
ray('Webhook settings saved', [
'webhook_enabled' => $this->settings->webhook_enabled,
'webhook_url' => $this->settings->webhook_url,
]);
}
$this->dispatch('success', 'Settings saved.');
}
public function sendTestNotification()
{
try {
$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->dispatch('success', 'Test notification sent.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.notifications.webhook');
}
}

View file

@ -37,7 +37,12 @@ public function submit()
'uuid' => (string) new Cuid2,
]);
return redirect()->route('project.show', $project->uuid);
$productionEnvironment = $project->environments()->where('name', 'production')->first();
return redirect()->route('project.resource.index', [
'project_uuid' => $project->uuid,
'environment_uuid' => $productionEnvironment->uuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -50,6 +50,28 @@ public function force_start()
}
}
public function copyLogsToClipboard(): string
{
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
if (! $logs) {
return '';
}
$markdown = "# Deployment Logs\n\n";
$markdown .= "```\n";
foreach ($logs as $log) {
if (isset($log['output'])) {
$markdown .= $log['output']."\n";
}
}
$markdown .= "```\n";
return $markdown;
}
public function cancel()
{
$deployment_uuid = $this->application_deployment_queue->deployment_uuid;

View file

@ -544,6 +544,9 @@ public function submit($showToaster = true)
{
try {
$this->authorize('update', $this->application);
$this->validate();
$this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->replaceStart(',', '')->trim();
$this->application->fqdn = str($this->application->fqdn)->trim()->explode(',')->map(function ($domain) {
@ -584,7 +587,6 @@ public function submit($showToaster = true)
return;
}
}
$this->validate();
if ($this->ports_exposes !== $this->application->ports_exposes || $this->is_container_label_escape_enabled !== $this->application->settings->is_container_label_escape_enabled) {
$this->resetDefaultLabels();

View file

@ -85,6 +85,7 @@ class BackupEdit extends Component
public function mount()
{
try {
$this->authorize('view', $this->backup->database);
$this->parameters = get_route_parameters();
$this->syncData();
} catch (Exception $e) {
@ -208,7 +209,7 @@ private function customValidate()
// Validate that disable_local_backup can only be true when S3 backup is enabled
if ($this->backup->disable_local_backup && ! $this->backup->save_s3) {
throw new \Exception('Local backup can only be disabled when S3 backup is enabled.');
$this->backup->disable_local_backup = $this->disableLocalBackup = false;
}
$isValid = validate_cron_expression($this->backup->frequency);

View file

@ -202,11 +202,6 @@ public function server()
public function render()
{
return view('livewire.project.database.backup-executions', [
'checkboxes' => [
['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently from S3 Storage'],
// ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently from SFTP Storage'],
],
]);
return view('livewire.project.database.backup-executions');
}
}

View file

@ -16,7 +16,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneClickhouse $database;
@ -56,8 +56,14 @@ public function getListeners()
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -3,10 +3,12 @@
namespace App\Livewire\Project\Database;
use Auth;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Configuration extends Component
{
use AuthorizesRequests;
public $currentRoute;
public $database;
@ -42,6 +44,8 @@ public function mount()
->where('uuid', request()->route('database_uuid'))
->firstOrFail();
$this->authorize('view', $database);
$this->database = $database;
$this->project = $project;
$this->environment = $environment;

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneDragonfly;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,7 +18,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneDragonfly $database;
@ -63,8 +62,14 @@ public function getListeners()
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first();
@ -249,13 +254,13 @@ public function regenerateSslCertificate()
$server = $this->database->destination->server;
$caCert = SslCertificate::where('server_id', $server->id)
$caCert = $server->sslCertificates()
->where('is_ca_certificate', true)
->first();
if (! $caCert) {
$server->generateCaCertificate();
$caCert = SslCertificate::where('server_id', $server->id)->where('is_ca_certificate', true)->first();
$caCert = $server->sslCertificates()->where('is_ca_certificate', true)->first();
}
if (! $caCert) {

View file

@ -131,6 +131,7 @@ public function getContainers()
if (is_null($resource)) {
abort(404);
}
$this->authorize('view', $resource);
$this->resource = $resource;
$this->server = $this->resource->destination->server;
$this->container = $this->resource->uuid;

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneKeydb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,7 +18,7 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneKeydb $database;
@ -59,15 +58,20 @@ public function getListeners()
return [
"echo-private:team.{$teamId},DatabaseProxyStopped" => 'databaseProxyStopped',
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
public function mount()
{
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
return;
}
$existingCert = $this->database->sslCertificates()->first();
@ -255,7 +259,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)
$caCert = $this->server->sslCertificates()
->where('is_ca_certificate', true)
->first();

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMariadb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,12 +18,38 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public Server $server;
public ?Server $server = null;
public StandaloneMariadb $database;
public string $name;
public ?string $description = null;
public string $mariadbRootPassword;
public string $mariadbUser;
public string $mariadbPassword;
public string $mariadbDatabase;
public ?string $mariadbConf = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $db_url = null;
public ?string $db_url_public = null;
@ -37,27 +62,26 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mariadb_root_password' => 'required',
'database.mariadb_user' => 'required',
'database.mariadb_password' => 'required',
'database.mariadb_database' => 'required',
'database.mariadb_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mariadbRootPassword' => 'required',
'mariadbUser' => 'required',
'mariadbPassword' => 'required',
'mariadbDatabase' => 'required',
'mariadbConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
];
}
@ -66,45 +90,96 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mariadb_root_password.required' => 'The Root Password field is required.',
'database.mariadb_user.required' => 'The MariaDB User field is required.',
'database.mariadb_password.required' => 'The MariaDB Password field is required.',
'database.mariadb_database.required' => 'The MariaDB Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mariadbRootPassword.required' => 'The Root Password field is required.',
'mariadbUser.required' => 'The MariaDB User field is required.',
'mariadbPassword.required' => 'The MariaDB Password field is required.',
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mariadb_root_password' => 'Root Password',
'database.mariadb_user' => 'User',
'database.mariadb_password' => 'Password',
'database.mariadb_database' => 'Database',
'database.mariadb_conf' => 'MariaDB Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Options',
'database.enable_ssl' => 'Enable SSL',
'name' => 'Name',
'description' => 'Description',
'mariadbRootPassword' => 'Root Password',
'mariadbUser' => 'User',
'mariadbPassword' => 'Password',
'mariadbDatabase' => 'Database',
'mariadbConf' => 'MariaDB Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Options',
'enableSsl' => 'Enable SSL',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mariadb_root_password = $this->mariadbRootPassword;
$this->database->mariadb_user = $this->mariadbUser;
$this->database->mariadb_password = $this->mariadbPassword;
$this->database->mariadb_database = $this->mariadbDatabase;
$this->database->mariadb_conf = $this->mariadbConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mariadbRootPassword = $this->database->mariadb_root_password;
$this->mariadbUser = $this->database->mariadb_user;
$this->mariadbPassword = $this->database->mariadb_password;
$this->mariadbDatabase = $this->database->mariadb_database;
$this->mariadbConf = $this->database->mariadb_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -132,11 +207,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -154,16 +228,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -173,10 +247,9 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
@ -187,7 +260,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -207,7 +280,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -231,6 +304,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMongodb;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,12 +18,38 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public Server $server;
public ?Server $server = null;
public StandaloneMongodb $database;
public string $name;
public ?string $description = null;
public ?string $mongoConf = null;
public string $mongoInitdbRootUsername;
public string $mongoInitdbRootPassword;
public string $mongoInitdbDatabase;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
public ?string $db_url_public = null;
@ -37,27 +62,26 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mongo_conf' => 'nullable',
'database.mongo_initdb_root_username' => 'required',
'database.mongo_initdb_root_password' => 'required',
'database.mongo_initdb_database' => 'required',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-full',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
'mongoInitdbRootUsername' => 'required',
'mongoInitdbRootPassword' => 'required',
'mongoInitdbDatabase' => 'required',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-full',
];
}
@ -66,45 +90,96 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mongo_initdb_root_username.required' => 'The Root Username field is required.',
'database.mongo_initdb_root_password.required' => 'The Root Password field is required.',
'database.mongo_initdb_database.required' => 'The MongoDB Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mongo_conf' => 'Mongo Configuration',
'database.mongo_initdb_root_username' => 'Root Username',
'database.mongo_initdb_root_password' => 'Root Password',
'database.mongo_initdb_database' => 'Database',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'mongoConf' => 'Mongo Configuration',
'mongoInitdbRootUsername' => 'Root Username',
'mongoInitdbRootPassword' => 'Root Password',
'mongoInitdbDatabase' => 'Database',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mongo_conf = $this->mongoConf;
$this->database->mongo_initdb_root_username = $this->mongoInitdbRootUsername;
$this->database->mongo_initdb_root_password = $this->mongoInitdbRootPassword;
$this->database->mongo_initdb_database = $this->mongoInitdbDatabase;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mongoConf = $this->database->mongo_conf;
$this->mongoInitdbRootUsername = $this->database->mongo_initdb_root_username;
$this->mongoInitdbRootPassword = $this->database->mongo_initdb_root_password;
$this->mongoInitdbDatabase = $this->database->mongo_initdb_database;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -114,12 +189,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -132,14 +207,13 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
if (str($this->database->mongo_conf)->isEmpty()) {
$this->database->mongo_conf = null;
if (str($this->mongoConf)->isEmpty()) {
$this->mongoConf = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -157,16 +231,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -176,16 +250,15 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -195,7 +268,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -215,7 +288,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -239,6 +312,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneMysql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,11 +18,39 @@ class General extends Component
{
use AuthorizesRequests;
protected $listeners = ['refresh'];
public StandaloneMysql $database;
public Server $server;
public ?Server $server = null;
public string $name;
public ?string $description = null;
public string $mysqlRootPassword;
public string $mysqlUser;
public string $mysqlPassword;
public string $mysqlDatabase;
public ?string $mysqlConf = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public ?string $db_url = null;
@ -37,28 +64,27 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.mysql_root_password' => 'required',
'database.mysql_user' => 'required',
'database.mysql_password' => 'required',
'database.mysql_database' => 'required',
'database.mysql_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mysqlRootPassword' => 'required',
'mysqlUser' => 'required',
'mysqlPassword' => 'required',
'mysqlDatabase' => 'required',
'mysqlConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:PREFERRED,REQUIRED,VERIFY_CA,VERIFY_IDENTITY',
];
}
@ -67,47 +93,100 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.mysql_root_password.required' => 'The Root Password field is required.',
'database.mysql_user.required' => 'The MySQL User field is required.',
'database.mysql_password.required' => 'The MySQL Password field is required.',
'database.mysql_database.required' => 'The MySQL Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'mysqlRootPassword.required' => 'The Root Password field is required.',
'mysqlUser.required' => 'The MySQL User field is required.',
'mysqlPassword.required' => 'The MySQL Password field is required.',
'mysqlDatabase.required' => 'The MySQL Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.mysql_root_password' => 'Root Password',
'database.mysql_user' => 'User',
'database.mysql_password' => 'Password',
'database.mysql_database' => 'Database',
'database.mysql_conf' => 'MySQL Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'mysqlRootPassword' => 'Root Password',
'mysqlUser' => 'User',
'mysqlPassword' => 'Password',
'mysqlDatabase' => 'Database',
'mysqlConf' => 'MySQL Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->mysql_root_password = $this->mysqlRootPassword;
$this->database->mysql_user = $this->mysqlUser;
$this->database->mysql_password = $this->mysqlPassword;
$this->database->mysql_database = $this->mysqlDatabase;
$this->database->mysql_conf = $this->mysqlConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->mysqlRootPassword = $this->database->mysql_root_password;
$this->mysqlUser = $this->database->mysql_user;
$this->mysqlPassword = $this->database->mysql_password;
$this->mysqlDatabase = $this->database->mysql_database;
$this->mysqlConf = $this->database->mysql_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -117,12 +196,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -135,11 +214,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -157,16 +235,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -176,16 +254,15 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -195,7 +272,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -215,7 +292,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -239,6 +316,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->syncData();
}
public function render()

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandalonePostgresql;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -21,7 +20,41 @@ class General extends Component
public StandalonePostgresql $database;
public Server $server;
public ?Server $server = null;
public string $name;
public ?string $description = null;
public string $postgresUser;
public string $postgresPassword;
public string $postgresDb;
public ?string $postgresInitdbArgs = null;
public ?string $postgresHostAuthMethod = null;
public ?string $postgresConf = null;
public ?array $initScripts = null;
public string $image;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public bool $enableSsl = false;
public ?string $sslMode = null;
public string $new_filename;
@ -39,7 +72,6 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'refresh' => '$refresh',
'save_init_script',
'delete_init_script',
];
@ -48,23 +80,23 @@ public function getListeners()
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.postgres_user' => 'required',
'database.postgres_password' => 'required',
'database.postgres_db' => 'required',
'database.postgres_initdb_args' => 'nullable',
'database.postgres_host_auth_method' => 'nullable',
'database.postgres_conf' => 'nullable',
'database.init_scripts' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'database.enable_ssl' => 'boolean',
'database.ssl_mode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'postgresUser' => 'required',
'postgresPassword' => 'required',
'postgresDb' => 'required',
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
'initScripts' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'enableSsl' => 'boolean',
'sslMode' => 'nullable|string|in:allow,prefer,require,verify-ca,verify-full',
];
}
@ -73,48 +105,105 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.postgres_user.required' => 'The Postgres User field is required.',
'database.postgres_password.required' => 'The Postgres Password field is required.',
'database.postgres_db.required' => 'The Postgres Database field is required.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'database.ssl_mode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'postgresUser.required' => 'The Postgres User field is required.',
'postgresPassword.required' => 'The Postgres Password field is required.',
'postgresDb.required' => 'The Postgres Database field is required.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.postgres_user' => 'Postgres User',
'database.postgres_password' => 'Postgres Password',
'database.postgres_db' => 'Postgres DB',
'database.postgres_initdb_args' => 'Postgres Initdb Args',
'database.postgres_host_auth_method' => 'Postgres Host Auth Method',
'database.postgres_conf' => 'Postgres Configuration',
'database.init_scripts' => 'Init Scripts',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Run Options',
'database.enable_ssl' => 'Enable SSL',
'database.ssl_mode' => 'SSL Mode',
'name' => 'Name',
'description' => 'Description',
'postgresUser' => 'Postgres User',
'postgresPassword' => 'Postgres Password',
'postgresDb' => 'Postgres DB',
'postgresInitdbArgs' => 'Postgres Initdb Args',
'postgresHostAuthMethod' => 'Postgres Host Auth Method',
'postgresConf' => 'Postgres Configuration',
'initScripts' => 'Init Scripts',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Run Options',
'enableSsl' => 'Enable SSL',
'sslMode' => 'SSL Mode',
];
public function mount()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->server = data_get($this->database, 'destination.server');
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
$existingCert = $this->database->sslCertificates()->first();
return;
}
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (Exception $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->postgres_user = $this->postgresUser;
$this->database->postgres_password = $this->postgresPassword;
$this->database->postgres_db = $this->postgresDb;
$this->database->postgres_initdb_args = $this->postgresInitdbArgs;
$this->database->postgres_host_auth_method = $this->postgresHostAuthMethod;
$this->database->postgres_conf = $this->postgresConf;
$this->database->init_scripts = $this->initScripts;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->ssl_mode = $this->sslMode;
$this->database->save();
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->postgresUser = $this->database->postgres_user;
$this->postgresPassword = $this->database->postgres_password;
$this->postgresDb = $this->database->postgres_db;
$this->postgresInitdbArgs = $this->database->postgres_initdb_args;
$this->postgresHostAuthMethod = $this->database->postgres_host_auth_method;
$this->postgresConf = $this->database->postgres_conf;
$this->initScripts = $this->database->init_scripts;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->sslMode = $this->database->ssl_mode;
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
}
}
@ -124,12 +213,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -137,7 +226,7 @@ public function instantSaveAdvanced()
}
}
public function updatedDatabaseSslMode()
public function updatedSslMode()
{
$this->instantSaveSSL();
}
@ -147,10 +236,8 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
} catch (Exception $e) {
return handleError($e, $this);
}
@ -169,7 +256,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->common_name,
@ -195,16 +282,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -214,10 +301,9 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
return handleError($e, $this);
}
@ -227,7 +313,7 @@ public function save_init_script($script)
{
$this->authorize('update', $this->database);
$initScripts = collect($this->database->init_scripts ?? []);
$initScripts = collect($this->initScripts ?? []);
$existingScript = $initScripts->firstWhere('filename', $script['filename']);
$oldScript = $initScripts->firstWhere('index', $script['index']);
@ -263,7 +349,7 @@ public function save_init_script($script)
$initScripts->push($script);
}
$this->database->init_scripts = $initScripts->values()
$this->initScripts = $initScripts->values()
->map(function ($item, $index) {
$item['index'] = $index;
@ -271,7 +357,7 @@ public function save_init_script($script)
})
->all();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Init script saved and updated.');
}
@ -279,7 +365,7 @@ public function delete_init_script($script)
{
$this->authorize('update', $this->database);
$collection = collect($this->database->init_scripts);
$collection = collect($this->initScripts);
$found = $collection->firstWhere('filename', $script['filename']);
if ($found) {
$container_name = $this->database->uuid;
@ -304,8 +390,8 @@ public function delete_init_script($script)
})
->all();
$this->database->init_scripts = $updatedScripts;
$this->database->save();
$this->initScripts = $updatedScripts;
$this->syncData(true);
$this->dispatch('refresh')->self();
$this->dispatch('success', 'Init script deleted from the database and the server.');
}
@ -319,23 +405,23 @@ public function save_new_init_script()
'new_filename' => 'required|string',
'new_content' => 'required|string',
]);
$found = collect($this->database->init_scripts)->firstWhere('filename', $this->new_filename);
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
if ($found) {
$this->dispatch('error', 'Filename already exists.');
return;
}
if (! isset($this->database->init_scripts)) {
$this->database->init_scripts = [];
if (! isset($this->initScripts)) {
$this->initScripts = [];
}
$this->database->init_scripts = array_merge($this->database->init_scripts, [
$this->initScripts = array_merge($this->initScripts, [
[
'index' => count($this->database->init_scripts),
'index' => count($this->initScripts),
'filename' => $this->new_filename,
'content' => $this->new_content,
],
]);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Init script added.');
$this->new_content = '';
$this->new_filename = '';
@ -346,11 +432,10 @@ public function submit()
try {
$this->authorize('update', $this->database);
if (str($this->database->public_port)->isEmpty()) {
$this->database->public_port = null;
if (str($this->publicPort)->isEmpty()) {
$this->publicPort = null;
}
$this->validate();
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);

View file

@ -6,7 +6,6 @@
use App\Actions\Database\StopDatabaseProxy;
use App\Helpers\SslHelper;
use App\Models\Server;
use App\Models\SslCertificate;
use App\Models\StandaloneRedis;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
@ -19,19 +18,39 @@ class General extends Component
{
use AuthorizesRequests;
public Server $server;
public ?Server $server = null;
public StandaloneRedis $database;
public string $redis_username;
public string $name;
public ?string $redis_password;
public ?string $description = null;
public string $redis_version;
public ?string $redisConf = null;
public ?string $db_url = null;
public string $image;
public ?string $db_url_public = null;
public ?string $portsMappings = null;
public ?bool $isPublic = null;
public ?int $publicPort = null;
public bool $isLogDrainEnabled = false;
public ?string $customDockerRunOptions = null;
public string $redisUsername;
public string $redisPassword;
public string $redisVersion;
public ?string $dbUrl = null;
public ?string $dbUrlPublic = null;
public bool $enableSsl = false;
public ?Carbon $certificateValidUntil = null;
@ -42,25 +61,24 @@ public function getListeners()
return [
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
'envsUpdated' => 'refresh',
'refresh',
];
}
protected function rules(): array
{
return [
'database.name' => ValidationPatterns::nameRules(),
'database.description' => ValidationPatterns::descriptionRules(),
'database.redis_conf' => 'nullable',
'database.image' => 'required',
'database.ports_mappings' => 'nullable',
'database.is_public' => 'nullable|boolean',
'database.public_port' => 'nullable|integer',
'database.is_log_drain_enabled' => 'nullable|boolean',
'database.custom_docker_run_options' => 'nullable',
'redis_username' => 'required',
'redis_password' => 'required',
'database.enable_ssl' => 'boolean',
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'redisConf' => 'nullable',
'image' => 'required',
'portsMappings' => 'nullable',
'isPublic' => 'nullable|boolean',
'publicPort' => 'nullable|integer',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
'redisUsername' => 'required',
'redisPassword' => 'required',
'enableSsl' => 'boolean',
];
}
@ -69,39 +87,87 @@ protected function messages(): array
return array_merge(
ValidationPatterns::combinedMessages(),
[
'database.name.required' => 'The Name field is required.',
'database.name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'database.description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'database.image.required' => 'The Docker Image field is required.',
'database.public_port.integer' => 'The Public Port must be an integer.',
'redis_username.required' => 'The Redis Username field is required.',
'redis_password.required' => 'The Redis Password field is required.',
'name.required' => 'The Name field is required.',
'name.regex' => 'The Name may only contain letters, numbers, spaces, dashes (-), underscores (_), dots (.), slashes (/), colons (:), and parentheses ().',
'description.regex' => 'The Description contains invalid characters. Only letters, numbers, spaces, and common punctuation (- _ . : / () \' " , ! ? @ # % & + = [] {} | ~ ` *) are allowed.',
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'redisUsername.required' => 'The Redis Username field is required.',
'redisPassword.required' => 'The Redis Password field is required.',
]
);
}
protected $validationAttributes = [
'database.name' => 'Name',
'database.description' => 'Description',
'database.redis_conf' => 'Redis Configuration',
'database.image' => 'Image',
'database.ports_mappings' => 'Port Mapping',
'database.is_public' => 'Is Public',
'database.public_port' => 'Public Port',
'database.custom_docker_run_options' => 'Custom Docker Options',
'redis_username' => 'Redis Username',
'redis_password' => 'Redis Password',
'database.enable_ssl' => 'Enable SSL',
'name' => 'Name',
'description' => 'Description',
'redisConf' => 'Redis Configuration',
'image' => 'Image',
'portsMappings' => 'Port Mapping',
'isPublic' => 'Is Public',
'publicPort' => 'Public Port',
'customDockerRunOptions' => 'Custom Docker Options',
'redisUsername' => 'Redis Username',
'redisPassword' => 'Redis Password',
'enableSsl' => 'Enable SSL',
];
public function mount()
{
$this->server = data_get($this->database, 'destination.server');
$this->refreshView();
$existingCert = $this->database->sslCertificates()->first();
try {
$this->authorize('view', $this->database);
$this->syncData();
$this->server = data_get($this->database, 'destination.server');
if (! $this->server) {
$this->dispatch('error', 'Database destination server is not configured.');
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
return;
}
$existingCert = $this->database->sslCertificates()->first();
if ($existingCert) {
$this->certificateValidUntil = $existingCert->valid_until;
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function syncData(bool $toModel = false)
{
if ($toModel) {
$this->validate();
$this->database->name = $this->name;
$this->database->description = $this->description;
$this->database->redis_conf = $this->redisConf;
$this->database->image = $this->image;
$this->database->ports_mappings = $this->portsMappings;
$this->database->is_public = $this->isPublic;
$this->database->public_port = $this->publicPort;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->enable_ssl = $this->enableSsl;
$this->database->save();
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
$this->redisConf = $this->database->redis_conf;
$this->image = $this->database->image;
$this->portsMappings = $this->database->ports_mappings;
$this->isPublic = $this->database->is_public;
$this->publicPort = $this->database->public_port;
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
$this->enableSsl = $this->database->enable_ssl;
$this->dbUrl = $this->database->internal_db_url;
$this->dbUrlPublic = $this->database->external_db_url;
$this->redisVersion = $this->database->getRedisVersion();
$this->redisUsername = $this->database->redis_username;
$this->redisPassword = $this->database->redis_password;
}
}
@ -111,12 +177,12 @@ public function instantSaveAdvanced()
$this->authorize('update', $this->database);
if (! $this->server->isLogDrainEnabled()) {
$this->database->is_log_drain_enabled = false;
$this->isLogDrainEnabled = false;
$this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.');
return;
}
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'Database updated.');
$this->dispatch('success', 'You need to restart the service for the changes to take effect.');
} catch (Exception $e) {
@ -129,20 +195,19 @@ public function submit()
try {
$this->authorize('manageEnvironment', $this->database);
$this->validate();
$this->syncData(true);
if (version_compare($this->redis_version, '6.0', '>=')) {
if (version_compare($this->redisVersion, '6.0', '>=')) {
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_USERNAME'],
['value' => $this->redis_username, 'resourceable_id' => $this->database->id]
['value' => $this->redisUsername, 'resourceable_id' => $this->database->id]
);
}
$this->database->runtime_environment_variables()->updateOrCreate(
['key' => 'REDIS_PASSWORD'],
['value' => $this->redis_password, 'resourceable_id' => $this->database->id]
['value' => $this->redisPassword, 'resourceable_id' => $this->database->id]
);
$this->database->save();
$this->dispatch('success', 'Database updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -156,16 +221,16 @@ public function instantSave()
try {
$this->authorize('update', $this->database);
if ($this->database->is_public && ! $this->database->public_port) {
if ($this->isPublic && ! $this->publicPort) {
$this->dispatch('error', 'Public port is required.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
if ($this->database->is_public) {
if ($this->isPublic) {
if (! str($this->database->status)->startsWith('running')) {
$this->dispatch('error', 'Database must be started to be publicly accessible.');
$this->database->is_public = false;
$this->isPublic = false;
return;
}
@ -175,10 +240,11 @@ public function instantSave()
StopDatabaseProxy::run($this->database);
$this->dispatch('success', 'Database is no longer publicly accessible.');
}
$this->db_url_public = $this->database->external_db_url;
$this->database->save();
$this->dbUrlPublic = $this->database->external_db_url;
$this->syncData(true);
} catch (\Throwable $e) {
$this->database->is_public = ! $this->database->is_public;
$this->isPublic = ! $this->isPublic;
$this->syncData(true);
return handleError($e, $this);
}
@ -189,7 +255,7 @@ public function instantSaveSSL()
try {
$this->authorize('update', $this->database);
$this->database->save();
$this->syncData(true);
$this->dispatch('success', 'SSL configuration updated.');
} catch (Exception $e) {
return handleError($e, $this);
@ -209,7 +275,7 @@ public function regenerateSslCertificate()
return;
}
$caCert = SslCertificate::where('server_id', $existingCert->server_id)->where('is_ca_certificate', true)->first();
$caCert = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
SslHelper::generateSslCertificate(
commonName: $existingCert->commonName,
@ -233,16 +299,7 @@ public function regenerateSslCertificate()
public function refresh(): void
{
$this->database->refresh();
$this->refreshView();
}
private function refreshView()
{
$this->db_url = $this->database->internal_db_url;
$this->db_url_public = $this->database->external_db_url;
$this->redis_version = $this->database->getRedisVersion();
$this->redis_username = $this->database->redis_username;
$this->redis_password = $this->database->redis_password;
$this->syncData();
}
public function render()

View file

@ -21,6 +21,14 @@ public function mount()
$this->projects = Project::ownedByCurrentTeam()->get()->map(function ($project) {
$project->settingsRoute = route('project.edit', ['project_uuid' => $project->uuid]);
$project->canUpdate = auth()->user()->can('update', $project);
$project->canCreateResource = auth()->user()->can('createAnyResource');
$firstEnvironment = $project->environments->first();
$project->addResourceRoute = $firstEnvironment
? route('project.resource.create', [
'project_uuid' => $project->uuid,
'environment_uuid' => $firstEnvironment->uuid,
])
: null;
return $project;
});

View file

@ -37,6 +37,10 @@ public function submit()
'dockerComposeRaw' => 'required',
]);
$this->dockerComposeRaw = Yaml::dump(Yaml::parse($this->dockerComposeRaw), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();

View file

@ -12,7 +12,11 @@
class DockerImage extends Component
{
public string $dockerImage = '';
public string $imageName = '';
public string $imageTag = '';
public string $imageSha256 = '';
public array $parameters;
@ -24,14 +28,88 @@ public function mount()
$this->query = request()->query();
}
/**
* Auto-parse image name when user pastes a complete Docker image reference
* Examples:
* - nginx:stable-alpine3.21-perl@sha256:4e272eef...
* - ghcr.io/user/app:v1.2.3
* - nginx@sha256:abc123...
*/
public function updatedImageName(): void
{
if (empty($this->imageName)) {
return;
}
// Don't auto-parse if user has already manually filled tag or sha256 fields
if (! empty($this->imageTag) || ! empty($this->imageSha256)) {
return;
}
// Only auto-parse if the image name contains a tag (:) or digest (@)
if (! str_contains($this->imageName, ':') && ! str_contains($this->imageName, '@')) {
return;
}
try {
$parser = new DockerImageParser;
$parser->parse($this->imageName);
// Extract the base image name (without tag/digest)
$baseImageName = $parser->getFullImageNameWithoutTag();
// Only update if parsing resulted in different base name
// This prevents unnecessary updates when user types just the name
if ($baseImageName !== $this->imageName) {
if ($parser->isImageHash()) {
// It's a SHA256 digest (takes priority over tag)
$this->imageSha256 = $parser->getTag();
$this->imageTag = '';
} elseif ($parser->getTag() !== 'latest' || str_contains($this->imageName, ':')) {
// It's a regular tag (only set if not default 'latest' or explicitly specified)
$this->imageTag = $parser->getTag();
$this->imageSha256 = '';
}
// Update imageName to just the base name
$this->imageName = $baseImageName;
}
} catch (\Exception $e) {
// If parsing fails, leave the image name as-is
// User will see validation error on submit
}
}
public function submit()
{
$this->validate([
'dockerImage' => 'required',
'imageName' => ['required', 'string'],
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]);
// Validate that either tag or sha256 is provided, but not both
if ($this->imageTag && $this->imageSha256) {
$this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
$this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
return;
}
// Build the full Docker image string
if ($this->imageSha256) {
// Strip 'sha256:' prefix if user pasted it
$sha256Hash = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
$dockerImage = $this->imageName.'@sha256:'.$sha256Hash;
} elseif ($this->imageTag) {
$dockerImage = $this->imageName.':'.$this->imageTag;
} else {
$dockerImage = $this->imageName.':latest';
}
// Parse using DockerImageParser to normalize the image reference
$parser = new DockerImageParser;
$parser->parse($this->dockerImage);
$parser->parse($dockerImage);
$destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@ -45,6 +123,16 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
$imageName .= '@sha256';
}
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
$application = Application::create([
'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0,
@ -52,8 +140,8 @@ public function submit()
'git_branch' => 'main',
'build_pack' => 'dockerimage',
'ports_exposes' => 80,
'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(),
'docker_registry_image_tag' => $parser->getTag(),
'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $imageTag,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,

View file

@ -55,7 +55,7 @@ class GithubPrivateRepository extends Component
public ?string $publish_directory = null;
// In case of docker compose
public ?string $base_directory = null;
public ?string $base_directory = '/';
public ?string $docker_compose_location = '/docker-compose.yaml';
// End of docker compose
@ -198,6 +198,7 @@ public function submit()
'build_pack' => $this->build_pack,
'ports_exposes' => $this->port,
'publish_directory' => $this->publish_directory,
'base_directory' => $this->base_directory,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
@ -212,7 +213,6 @@ public function submit()
}
if ($this->build_pack === 'dockercompose') {
$application['docker_compose_location'] = $this->docker_compose_location;
$application['base_directory'] = $this->base_directory;
}
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;

View file

@ -81,7 +81,7 @@ public function mount()
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
if ($oneClickServiceName === 'cloudflared') {
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
data_set($service_payload, 'connect_to_docker_network', true);
}
$service = Service::create($service_payload);

View file

@ -33,6 +33,8 @@ public function getListeners()
return [
"echo-private:team.{$teamId},ServiceChecked" => 'serviceChecked',
'refreshServices' => 'refreshServices',
'refresh' => 'refreshServices',
];
}

View file

@ -73,6 +73,7 @@ public function submit()
}
$this->application->service->parse();
$this->dispatch('refresh');
$this->dispatch('refreshServices');
$this->dispatch('configurationChanged');
} catch (\Throwable $e) {
$originalFqdn = $this->application->getOriginal('fqdn');

View file

@ -34,6 +34,8 @@ class FileStorage extends Component
public bool $permanently_delete = true;
public bool $isReadOnly = false;
protected $rules = [
'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required',
@ -52,6 +54,8 @@ public function mount()
$this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
}
public function convertToDirectory()

View file

@ -101,6 +101,10 @@ public function submit($notify = true)
{
try {
$this->validate();
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->service->docker_compose_raw);
$this->service->save();
$this->service->saveExtraFields($this->fields);
$this->service->parse();

View file

@ -14,6 +14,22 @@ class Storage extends Component
public $fileStorage;
public $isSwarm = false;
public string $name = '';
public string $mount_path = '';
public ?string $host_path = null;
public string $file_storage_path = '';
public ?string $file_storage_content = null;
public string $file_storage_directory_source = '';
public string $file_storage_directory_destination = '';
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
@ -27,6 +43,18 @@ public function getListeners()
public function mount()
{
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
} else {
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
if ($this->resource->destination->server->isSwarm()) {
$this->isSwarm = true;
}
}
$this->refreshStorages();
}
@ -39,30 +67,151 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->dispatch('$refresh');
$this->resource->refresh();
}
public function addNewVolume($data)
public function getFilesProperty()
{
return $this->fileStorage->where('is_directory', false);
}
public function getDirectoriesProperty()
{
return $this->fileStorage->where('is_directory', true);
}
public function getVolumeCountProperty()
{
return $this->resource->persistentStorages()->count();
}
public function getFileCountProperty()
{
return $this->files->count();
}
public function getDirectoryCountProperty()
{
return $this->directories->count();
}
public function submitPersistentVolume()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'name' => 'required|string',
'mount_path' => 'required|string',
'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
]);
$name = $this->resource->uuid.'-'.$this->name;
LocalPersistentVolume::create([
'name' => $data['name'],
'mount_path' => $data['mount_path'],
'host_path' => $data['host_path'],
'name' => $name,
'mount_path' => $this->mount_path,
'host_path' => $this->host_path,
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->getMorphClass(),
]);
$this->resource->refresh();
$this->dispatch('success', 'Storage added successfully');
$this->dispatch('clearAddStorage');
$this->dispatch('refreshStorages');
$this->dispatch('success', 'Volume added successfully');
$this->dispatch('closeStorageModal', 'volume');
$this->clearForm();
$this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_path' => 'required|string',
'file_storage_content' => 'nullable|string',
]);
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} else {
throw new \Exception('No valid resource type for file mount storage type!');
}
\App\Models\LocalFileVolume::create([
'fs_path' => $fs_path,
'mount_path' => $this->file_storage_path,
'content' => $this->file_storage_content,
'is_directory' => false,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->dispatch('success', 'File mount added successfully');
$this->dispatch('closeStorageModal', 'file');
$this->clearForm();
$this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorageDirectory()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_directory_source' => 'required|string',
'file_storage_directory_destination' => 'required|string',
]);
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
\App\Models\LocalFileVolume::create([
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
'is_directory' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->dispatch('success', 'Directory mount added successfully');
$this->dispatch('closeStorageModal', 'directory');
$this->clearForm();
$this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function clearForm()
{
$this->name = '';
$this->mount_path = '';
$this->host_path = null;
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
} else {
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
}
public function render()
{
return view('livewire.project.service.storage');

View file

@ -212,6 +212,12 @@ private function handleSingleSubmit($data)
$environment = $this->createEnvironmentVariable($data);
$environment->order = $maxOrder + 1;
$environment->save();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->dispatch('success', 'Environment variable added.');
}
private function createEnvironmentVariable($data)
@ -300,6 +306,9 @@ private function updateOrCreateVariables($isPreview, $variables)
public function refreshEnvs()
{
$this->resource->refresh();
// Clear computed property cache to force refresh
unset($this->environmentVariables);
unset($this->environmentVariablesPreview);
$this->getDevView();
}
}

View file

@ -1,174 +0,0 @@
<?php
namespace App\Livewire\Project\Shared\Storages;
use App\Models\Application;
use App\Models\LocalFileVolume;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Add extends Component
{
use AuthorizesRequests;
public $resource;
public $uuid;
public $parameters;
public $isSwarm = false;
public string $name;
public string $mount_path;
public ?string $host_path = null;
public string $file_storage_path;
public ?string $file_storage_content = null;
public string $file_storage_directory_source;
public string $file_storage_directory_destination;
public $rules = [
'name' => 'required|string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
'file_storage_path' => 'string',
'file_storage_content' => 'nullable|string',
'file_storage_directory_source' => 'string',
'file_storage_directory_destination' => 'string',
];
protected $listeners = ['clearAddStorage' => 'clear'];
protected $validationAttributes = [
'name' => 'name',
'mount_path' => 'mount',
'host_path' => 'host',
'file_storage_path' => 'file storage path',
'file_storage_content' => 'file storage content',
'file_storage_directory_source' => 'file storage directory source',
'file_storage_directory_destination' => 'file storage directory destination',
];
public function mount()
{
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
} else {
$this->file_storage_directory_source = application_configuration_dir()."/{$this->resource->uuid}";
}
$this->uuid = $this->resource->uuid;
$this->parameters = get_route_parameters();
if (data_get($this->parameters, 'application_uuid')) {
$applicationUuid = $this->parameters['application_uuid'];
$application = Application::where('uuid', $applicationUuid)->first();
if (! $application) {
abort(404);
}
if ($application->destination->server->isSwarm()) {
$this->isSwarm = true;
$this->rules['host_path'] = 'required|string';
}
}
}
public function submitFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_path' => 'string',
'file_storage_content' => 'nullable|string',
]);
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
if ($this->resource->getMorphClass() === \App\Models\Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} else {
throw new \Exception('No valid resource type for file mount storage type!');
}
LocalFileVolume::create(
[
'fs_path' => $fs_path,
'mount_path' => $this->file_storage_path,
'content' => $this->file_storage_content,
'is_directory' => false,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
],
);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorageDirectory()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'file_storage_directory_source' => 'string',
'file_storage_directory_destination' => 'string',
]);
$this->file_storage_directory_source = trim($this->file_storage_directory_source);
$this->file_storage_directory_source = str($this->file_storage_directory_source)->start('/')->value();
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
LocalFileVolume::create(
[
'fs_path' => $this->file_storage_directory_source,
'mount_path' => $this->file_storage_directory_destination,
'is_directory' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
],
);
$this->dispatch('refreshStorages');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitPersistentVolume()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'name' => 'required|string',
'mount_path' => 'required|string',
'host_path' => 'string|nullable',
]);
$name = $this->uuid.'-'.$this->name;
$this->dispatch('addNewVolume', [
'name' => $name,
'mount_path' => $this->mount_path,
'host_path' => $this->host_path,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function clear()
{
$this->name = '';
$this->mount_path = '';
$this->host_path = null;
}
}

View file

@ -37,6 +37,11 @@ class Show extends Component
'host_path' => 'host',
];
public function mount()
{
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
public function submit()
{
$this->authorize('update', $this->resource);

View file

@ -0,0 +1,101 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScriptForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = true;
public ?int $scriptId = null;
public string $name = '';
public string $script = '';
public function mount(?int $scriptId = null)
{
if ($scriptId) {
$this->scriptId = $scriptId;
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('update', $cloudInitScript);
$this->name = $cloudInitScript->name;
$this->script = $cloudInitScript->script;
} else {
$this->authorize('create', CloudInitScript::class);
}
}
protected function rules(): array
{
return [
'name' => 'required|string|max:255',
'script' => ['required', 'string', new \App\Rules\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 save()
{
$this->validate();
try {
if ($this->scriptId) {
// Update existing script
$cloudInitScript = CloudInitScript::ownedByCurrentTeam()->findOrFail($this->scriptId);
$this->authorize('update', $cloudInitScript);
$cloudInitScript->update([
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script updated successfully.';
} else {
// Create new script
$this->authorize('create', CloudInitScript::class);
CloudInitScript::create([
'team_id' => currentTeam()->id,
'name' => $this->name,
'script' => $this->script,
]);
$message = 'Cloud-init script created successfully.';
}
// Only reset fields if creating (not editing)
if (! $this->scriptId) {
$this->reset(['name', 'script']);
}
$this->dispatch('scriptSaved');
$this->dispatch('success', $message);
if ($this->modal_mode) {
$this->dispatch('closeModal');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-script-form');
}
}

View file

@ -0,0 +1,52 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudInitScript;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudInitScripts extends Component
{
use AuthorizesRequests;
public $scripts;
public function mount()
{
$this->authorize('viewAny', CloudInitScript::class);
$this->loadScripts();
}
public function getListeners()
{
return [
'scriptSaved' => 'loadScripts',
];
}
public function loadScripts()
{
$this->scripts = CloudInitScript::ownedByCurrentTeam()->orderBy('created_at', 'desc')->get();
}
public function deleteScript(int $scriptId)
{
try {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($scriptId);
$this->authorize('delete', $script);
$script->delete();
$this->loadScripts();
$this->dispatch('success', 'Cloud-init script deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-init-scripts');
}
}

View file

@ -0,0 +1,99 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Livewire\Component;
class CloudProviderTokenForm extends Component
{
use AuthorizesRequests;
public bool $modal_mode = false;
public string $provider = 'hetzner';
public string $token = '';
public string $name = '';
public function mount()
{
$this->authorize('create', CloudProviderToken::class);
}
protected function rules(): array
{
return [
'provider' => 'required|string|in:hetzner,digitalocean',
'token' => 'required|string',
'name' => 'required|string|max:255',
];
}
protected function messages(): array
{
return [
'provider.required' => 'Please select a cloud provider.',
'provider.in' => 'Invalid cloud provider selected.',
'token.required' => 'API token is required.',
'name.required' => 'Token name is required.',
];
}
private function validateToken(string $provider, string $token): bool
{
try {
if ($provider === 'hetzner') {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
ray($response);
return $response->successful();
}
// Add other providers here in the future
// if ($provider === 'digitalocean') { ... }
return false;
} catch (\Throwable $e) {
return false;
}
}
public function addToken()
{
$this->validate();
try {
// Validate the token with the provider's API
if (! $this->validateToken($this->provider, $this->token)) {
return $this->dispatch('error', 'Invalid API token. Please check your token and try again.');
}
$savedToken = CloudProviderToken::create([
'team_id' => currentTeam()->id,
'provider' => $this->provider,
'token' => $this->token,
'name' => $this->name,
]);
$this->reset(['token', 'name']);
// Dispatch event with token ID so parent components can react
$this->dispatch('tokenAdded', tokenId: $savedToken->id);
$this->dispatch('success', 'Cloud provider token added successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-token-form');
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Livewire\Security;
use App\Models\CloudProviderToken;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class CloudProviderTokens extends Component
{
use AuthorizesRequests;
public $tokens;
public function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
}
public function getListeners()
{
return [
'tokenAdded' => 'loadTokens',
];
}
public function loadTokens()
{
$this->tokens = CloudProviderToken::ownedByCurrentTeam()->get();
}
public function deleteToken(int $tokenId)
{
try {
$token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId);
$this->authorize('delete', $token);
// Check if any servers are using this token
if ($token->hasServers()) {
$serverCount = $token->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;
}
$token->delete();
$this->loadTokens();
$this->dispatch('success', 'Cloud provider token deleted successfully.');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.security.cloud-provider-tokens');
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Livewire\Security;
use Livewire\Component;
class CloudTokens extends Component
{
public function render()
{
return view('livewire.security.cloud-tokens');
}
}

View file

@ -21,6 +21,8 @@ class Create extends Component
public ?string $publicKey = null;
public bool $modal_mode = false;
protected function rules(): array
{
return [
@ -77,6 +79,14 @@ public function createPrivateKey()
'team_id' => currentTeam()->id,
]);
// If in modal mode, dispatch event and don't redirect
if ($this->modal_mode) {
$this->dispatch('privateKeyCreated', keyId: $privateKey->id);
$this->dispatch('success', 'Private key created successfully.');
return;
}
return $this->redirectAfterCreation($privateKey);
} catch (\Throwable $e) {
return handleError($e, $this);

View file

@ -39,7 +39,7 @@ public function mount(string $server_uuid)
public function loadCaCertificate()
{
$this->caCertificate = SslCertificate::where('server_id', $this->server->id)->where('is_ca_certificate', true)->first();
$this->caCertificate = $this->server->sslCertificates()->where('is_ca_certificate', true)->first();
if ($this->caCertificate) {
$this->certificateContent = $this->caCertificate->ssl_certificate;

View file

@ -0,0 +1,144 @@
<?php
namespace App\Livewire\Server\CloudProviderToken;
use App\Models\CloudProviderToken;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Show extends Component
{
use AuthorizesRequests;
public Server $server;
public $cloudProviderTokens = [];
public $parameters = [];
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
$this->loadTokens();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
];
}
public function loadTokens()
{
$this->cloudProviderTokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
$this->loadTokens();
}
public function setCloudProviderToken($tokenId)
{
$ownedToken = CloudProviderToken::ownedByCurrentTeam()->find($tokenId);
if (is_null($ownedToken)) {
$this->dispatch('error', 'You are not allowed to use this token.');
return;
}
try {
$this->authorize('update', $this->server);
// Validate the token works and can access this specific server
$validationResult = $this->validateTokenForServer($ownedToken);
if (! $validationResult['valid']) {
$this->dispatch('error', $validationResult['error']);
return;
}
$this->server->cloudProviderToken()->associate($ownedToken);
$this->server->save();
$this->dispatch('success', 'Hetzner token updated successfully.');
$this->dispatch('refreshServerShow');
} catch (\Exception $e) {
$this->server->refresh();
$this->dispatch('error', $e->getMessage());
}
}
private function validateTokenForServer(CloudProviderToken $token): array
{
try {
// First, validate the token itself
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if (! $response->successful()) {
return [
'valid' => false,
'error' => 'This token is invalid or has insufficient permissions.',
];
}
// Check if this token can access the specific Hetzner server
if ($this->server->hetzner_server_id) {
$serverResponse = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get("https://api.hetzner.cloud/v1/servers/{$this->server->hetzner_server_id}");
if (! $serverResponse->successful()) {
return [
'valid' => false,
'error' => 'This token cannot access this server. It may belong to a different Hetzner project.',
];
}
}
return ['valid' => true];
} catch (\Throwable $e) {
return [
'valid' => false,
'error' => 'Failed to validate token: '.$e->getMessage(),
];
}
}
public function validateToken()
{
try {
$token = $this->server->cloudProviderToken;
if (! $token) {
$this->dispatch('error', 'No Hetzner token is associated with this server.');
return;
}
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'Bearer '.$token->token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
if ($response->successful()) {
$this->dispatch('success', 'Hetzner token is valid and working.');
} else {
$this->dispatch('error', 'Hetzner token is invalid or has insufficient permissions.');
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.cloud-provider-token.show');
}
}

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Server;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Team;
use Livewire\Component;
@ -12,6 +13,8 @@ class Create extends Component
public bool $limit_reached = false;
public bool $has_hetzner_tokens = false;
public function mount()
{
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
@ -21,6 +24,11 @@ public function mount()
return;
}
$this->limit_reached = Team::serverLimitReached();
// Check if user has Hetzner tokens
$this->has_hetzner_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->exists();
}
public function render()

View file

@ -16,6 +16,8 @@ class Delete extends Component
public Server $server;
public bool $delete_from_hetzner = false;
public function mount(string $server_uuid)
{
try {
@ -41,8 +43,15 @@ public function delete($password)
return;
}
$this->server->delete();
DeleteServer::dispatch($this->server);
DeleteServer::dispatch(
$this->server->id,
$this->delete_from_hetzner,
$this->server->hetzner_server_id,
$this->server->cloud_provider_token_id,
$this->server->team_id
);
return redirect()->route('server.index');
} catch (\Throwable $e) {
@ -52,6 +61,18 @@ public function delete($password)
public function render()
{
return view('livewire.server.delete');
$checkboxes = [];
if ($this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
'label' => 'Also delete server from Hetzner Cloud',
'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.',
];
}
return view('livewire.server.delete', [
'checkboxes' => $checkboxes,
]);
}
}

View file

@ -118,17 +118,31 @@ public function checkProxyStatus()
public function showNotification()
{
$this->server->refresh();
$this->proxyStatus = $this->server->proxy->status ?? 'unknown';
$forceStop = $this->server->proxy->force_stop ?? false;
switch ($this->proxyStatus) {
case 'running':
$this->loadProxyConfiguration();
$this->dispatch('success', 'Proxy is running.');
break;
case 'restarting':
$this->dispatch('info', 'Initiating proxy restart.');
break;
case 'exited':
$this->dispatch('info', 'Proxy has exited.');
break;
case 'stopping':
$this->dispatch('info', 'Proxy is stopping.');
break;
case 'starting':
$this->dispatch('info', 'Proxy is starting.');
break;
case 'unknown':
$this->dispatch('info', 'Proxy status is unknown.');
break;
default:
$this->dispatch('info', 'Proxy status updated.');
break;
}

View file

@ -0,0 +1,587 @@
<?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\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByHetzner extends Component
{
use AuthorizesRequests;
// Step tracking
public int $current_step = 1;
// Locked data
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
// Step 1: Token selection
public ?int $selected_token_id = null;
// Step 2: Server configuration
public array $locations = [];
public array $images = [];
public array $serverTypes = [];
public array $hetznerSshKeys = [];
public ?string $selected_location = null;
public ?int $selected_image = null;
public ?string $selected_server_type = null;
public array $selectedHetznerSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public bool $enable_ipv4 = true;
public bool $enable_ipv6 = true;
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 function mount()
{
$this->authorize('viewAny', CloudProviderToken::class);
$this->loadTokens();
$this->loadSavedCloudInitScripts();
$this->server_name = generate_random_name();
if ($this->private_keys->count() > 0) {
$this->private_key_id = $this->private_keys->first()->id;
}
}
public function loadSavedCloudInitScripts()
{
$this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get();
}
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];
}
public function resetSelection()
{
$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 loadTokens()
{
$this->available_tokens = CloudProviderToken::ownedByCurrentTeam()
->where('provider', 'hetzner')
->get();
}
public function handleTokenAdded($tokenId)
{
// Refresh token list
$this->loadTokens();
// Auto-select the new token
$this->selected_token_id = $tokenId;
// Automatically proceed to next step
$this->nextStep();
}
public function handlePrivateKeyCreated($keyId)
{
// Refresh private keys list
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
// Auto-select the new key
$this->private_key_id = $keyId;
// Clear validation errors for private_key_id
$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_location' => 'required|string',
'selected_image' => 'required|integer',
'selected_server_type' => 'required|string',
'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id,
'selectedHetznerSshKeyIds' => 'nullable|array',
'selectedHetznerSshKeyIds.*' => 'integer',
'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new \App\Rules\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 Hetzner token.',
'selected_token_id.exists' => 'Selected token not found.',
];
}
public function selectToken(int $tokenId)
{
$this->selected_token_id = $tokenId;
}
private function validateHetznerToken(string $token): bool
{
try {
$response = Http::withHeaders([
'Authorization' => 'Bearer '.$token,
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers');
return $response->successful();
} catch (\Throwable $e) {
return false;
}
}
private function getHetznerToken(): string
{
if ($this->selected_token_id) {
$token = $this->available_tokens->firstWhere('id', $this->selected_token_id);
return $token ? $token->token : '';
}
return '';
}
public function nextStep()
{
// Validate step 1 - just need a token selected
$this->validate([
'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id',
]);
try {
$hetznerToken = $this->getHetznerToken();
if (! $hetznerToken) {
return $this->dispatch('error', 'Please select a valid Hetzner token.');
}
// Load Hetzner data
$this->loadHetznerData($hetznerToken);
// Move to step 2
$this->current_step = 2;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function previousStep()
{
$this->current_step = 1;
}
private function loadHetznerData(string $token)
{
$this->loading_data = true;
try {
$hetznerService = new HetznerService($token);
$this->locations = $hetznerService->getLocations();
$this->serverTypes = $hetznerService->getServerTypes();
// Get images and sort by name
$images = $hetznerService->getImages();
ray('Raw images from Hetzner API', [
'total_count' => count($images),
'types' => collect($images)->pluck('type')->unique()->values(),
'sample' => array_slice($images, 0, 3),
]);
$this->images = collect($images)
->filter(function ($image) {
// Only system images
if (! isset($image['type']) || $image['type'] !== 'system') {
return false;
}
// Filter out deprecated images
if (isset($image['deprecated']) && $image['deprecated'] === true) {
return false;
}
return true;
})
->sortBy('name')
->values()
->toArray();
ray('Filtered images', [
'filtered_count' => count($this->images),
'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(),
]);
// Load SSH keys from Hetzner
$this->hetznerSshKeys = $hetznerService->getSshKeys();
ray('Hetzner SSH Keys', [
'total_count' => count($this->hetznerSshKeys),
'keys' => $this->hetznerSshKeys,
]);
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
throw $e;
}
}
private function getCpuVendorInfo(array $serverType): string|null
{
$name = strtolower($serverType['name'] ?? '');
if (str_starts_with($name, 'ccx')) {
return 'AMD Milan EPYC™';
} elseif (str_starts_with($name, 'cpx')) {
return 'AMD EPYC™';
} elseif (str_starts_with($name, 'cx')) {
return 'Intel® Xeon®';
} elseif (str_starts_with($name, 'cax')) {
return 'Ampere® Altra®';
}
return null;
}
public function getAvailableServerTypesProperty()
{
ray('Getting available server types', [
'selected_location' => $this->selected_location,
'total_server_types' => count($this->serverTypes),
]);
if (! $this->selected_location) {
return $this->serverTypes;
}
$filtered = collect($this->serverTypes)
->filter(function ($type) {
if (! isset($type['locations'])) {
return false;
}
$locationNames = collect($type['locations'])->pluck('name')->toArray();
return in_array($this->selected_location, $locationNames);
})
->map(function ($serverType) {
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
return $serverType;
})
->values()
->toArray();
ray('Filtered server types', [
'selected_location' => $this->selected_location,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getAvailableImagesProperty()
{
ray('Getting available images', [
'selected_server_type' => $this->selected_server_type,
'total_images' => count($this->images),
'images' => $this->images,
]);
if (! $this->selected_server_type) {
return $this->images;
}
$serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
ray('Server type data', $serverType);
if (! $serverType || ! isset($serverType['architecture'])) {
ray('No architecture in server type, returning all');
return $this->images;
}
$architecture = $serverType['architecture'];
$filtered = collect($this->images)
->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture)
->values()
->toArray();
ray('Filtered images', [
'architecture' => $architecture,
'filtered_count' => count($filtered),
]);
return $filtered;
}
public function getSelectedServerPriceProperty(): ?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 = $serverType['prices'][0]['price_monthly']['gross'];
return '€'.number_format($price, 2);
}
public function updatedSelectedLocation($value)
{
ray('Location selected', $value);
// Reset server type and image when location changes
$this->selected_server_type = null;
$this->selected_image = null;
}
public function updatedSelectedServerType($value)
{
ray('Server type selected', $value);
// Reset image when server type changes
$this->selected_image = null;
}
public function updatedSelectedImage($value)
{
ray('Image selected', $value);
}
public function updatedSelectedCloudInitScriptId($value)
{
if ($value) {
$script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value);
$this->cloud_init_script = $script->script;
$this->cloud_init_script_name = $script->name;
}
}
public function clearCloudInitScript()
{
$this->selected_cloud_init_script_id = null;
$this->cloud_init_script = '';
$this->cloud_init_script_name = '';
$this->save_cloud_init_script = false;
}
private function createHetznerServer(string $token): array
{
$hetznerService = new HetznerService($token);
// Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$publicKey = $privateKey->getPublicKey();
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
ray('Private Key Info', [
'private_key_id' => $this->private_key_id,
'sha256_fingerprint' => $privateKey->fingerprint,
'md5_fingerprint' => $md5Fingerprint,
]);
// Check if SSH key already exists on Hetzner by comparing MD5 fingerprints
$existingSshKeys = $hetznerService->getSshKeys();
$existingKey = null;
ray('Existing SSH Keys on Hetzner', $existingSshKeys);
foreach ($existingSshKeys as $key) {
if ($key['fingerprint'] === $md5Fingerprint) {
$existingKey = $key;
break;
}
}
// Upload SSH key if it doesn't exist
if ($existingKey) {
$sshKeyId = $existingKey['id'];
ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]);
} else {
$sshKeyName = $privateKey->name;
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
$sshKeyId = $uploadedKey['id'];
ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]);
}
// Normalize server name to lowercase for RFC 1123 compliance
$normalizedServerName = strtolower(trim($this->server_name));
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
$sshKeys = array_merge(
[$sshKeyId], // Coolify key (always included)
$this->selectedHetznerSshKeyIds // User-selected Hetzner keys
);
// Remove duplicates in case the Coolify key was also selected
$sshKeys = array_unique($sshKeys);
$sshKeys = array_values($sshKeys); // Re-index array
// Prepare server creation parameters
$params = [
'name' => $normalizedServerName,
'server_type' => $this->selected_server_type,
'image' => $this->selected_image,
'location' => $this->selected_location,
'start_after_create' => true,
'ssh_keys' => $sshKeys,
'public_net' => [
'enable_ipv4' => $this->enable_ipv4,
'enable_ipv6' => $this->enable_ipv6,
],
];
// Add cloud-init script if provided
if (! empty($this->cloud_init_script)) {
$params['user_data'] = $this->cloud_init_script;
}
ray('Server creation parameters', $params);
// Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params);
ray('Hetzner server created', $hetznerServer);
return $hetznerServer;
}
public function submit()
{
$this->validate();
try {
$this->authorize('create', Server::class);
if (Team::serverLimitReached()) {
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
}
// Save cloud-init script if requested
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,
]);
}
$hetznerToken = $this->getHetznerToken();
// Create server on Hetzner
$hetznerServer = $this->createHetznerServer($hetznerToken);
// Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null;
if ($this->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
} elseif ($this->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
if (! $ipAddress) {
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
}
// Create server in Coolify database
$server = Server::create([
'name' => $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,
'hetzner_server_id' => $hetznerServer['id'],
]);
$server->proxy->set('status', 'exited');
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
return redirect()->route('server.show', $server->uuid);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function render()
{
return view('livewire.server.new.by-hetzner');
}
}

View file

@ -67,13 +67,21 @@ class Show extends Component
public string $serverTimezone;
public ?string $hetznerServerStatus = null;
public bool $hetznerServerManuallyStarted = false;
public bool $isValidating = false;
public function getListeners()
{
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
return [
'refreshServerShow' => 'refresh',
'refreshServer' => '$refresh',
"echo-private:team.{$teamId},SentinelRestarted" => 'handleSentinelRestarted',
"echo-private:team.{$teamId},ServerValidated" => 'handleServerValidated',
];
}
@ -138,6 +146,10 @@ public function mount(string $server_uuid)
if (! $this->server->isEmpty()) {
$this->isBuildServerLocked = true;
}
// Load saved Hetzner status and validation state
$this->hetznerServerStatus = $this->server->hetzner_server_status;
$this->isValidating = $this->server->is_validating ?? false;
} catch (\Throwable $e) {
return handleError($e, $this);
}
@ -218,6 +230,7 @@ public function syncData(bool $toModel = false)
$this->isSentinelDebugEnabled = $this->server->settings->is_sentinel_debug_enabled;
$this->sentinelUpdatedAt = $this->server->sentinel_updated_at;
$this->serverTimezone = $this->server->settings->server_timezone;
$this->isValidating = $this->server->is_validating ?? false;
}
}
@ -361,6 +374,87 @@ public function instantSave()
}
}
public function checkHetznerServerStatus(bool $manual = false)
{
try {
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
// Save status to database without triggering model events
if ($this->server->hetzner_server_status !== $this->hetznerServerStatus) {
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
}
if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
}
// If Hetzner server is off but Coolify thinks it's still reachable, update Coolify's state
if ($this->hetznerServerStatus === 'off' && $this->server->settings->is_reachable) {
['uptime' => $uptime, 'error' => $error] = $this->server->validateConnection();
if ($uptime) {
$this->dispatch('success', 'Server is reachable.');
$this->server->settings->is_reachable = $this->isReachable = true;
$this->server->settings->is_usable = $this->isUsable = true;
$this->server->settings->save();
ServerReachabilityChanged::dispatch($this->server);
} else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);
return;
}
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function handleServerValidated($event = null)
{
// Check if event is for this server
if ($event && isset($event['serverUuid']) && $event['serverUuid'] !== $this->server->uuid) {
return;
}
// Refresh server data
$this->server->refresh();
$this->syncData();
// Update validation state
$this->isValidating = $this->server->is_validating ?? false;
$this->dispatch('refreshServerShow');
$this->dispatch('refreshServer');
}
public function startHetznerServer()
{
try {
if (! $this->server->hetzner_server_id || ! $this->server->cloudProviderToken) {
$this->dispatch('error', 'This server is not associated with a Hetzner Cloud server or token.');
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';
$this->server->update(['hetzner_server_status' => 'starting']);
$this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running
$this->dispatch('success', 'Hetzner server is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submit()
{
try {

View file

@ -4,6 +4,7 @@
use App\Actions\Proxy\CheckProxy;
use App\Actions\Proxy\StartProxy;
use App\Events\ServerValidated;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@ -63,6 +64,19 @@ public function startValidatingAfterAsking()
$this->init();
}
public function retry()
{
$this->authorize('update', $this->server);
$this->uptime = null;
$this->supported_os_type = null;
$this->docker_installed = null;
$this->docker_compose_installed = null;
$this->docker_version = null;
$this->error = null;
$this->number_of_tries = 0;
$this->init();
}
public function validateConnection()
{
$this->authorize('update', $this->server);
@ -136,8 +150,12 @@ public function validateDockerVersion()
} else {
$this->docker_version = $this->server->validateDockerEngineVersion();
if ($this->docker_version) {
// Mark validation as complete
$this->server->update(['is_validating' => false]);
$this->dispatch('refreshServerShow');
$this->dispatch('refreshBoardingIndex');
ServerValidated::dispatch($this->server->team_id, $this->server->uuid);
$this->dispatch('success', 'Server validated, proxy is starting in a moment.');
$proxyShouldRun = CheckProxy::run($this->server, true);
if (! $proxyShouldRun) {

View file

@ -120,6 +120,8 @@ public function addCoolifyDatabase()
public function submit()
{
$this->validate();
$this->database->update([
'name' => $this->name,
'description' => $this->description,

View file

@ -5,6 +5,7 @@
use App\Models\S3Storage;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class Form extends Component
@ -91,9 +92,24 @@ public function submit()
try {
$this->authorize('update', $this->storage);
$this->validate();
$this->testConnection();
DB::transaction(function () {
$this->validate();
$this->storage->save();
// Test connection with new values - if this fails, transaction will rollback
$this->storage->testConnection(shouldSave: false);
// If we get here, the connection test succeeded
$this->storage->is_usable = true;
$this->storage->unusable_email_sent = false;
$this->storage->save();
});
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
// Refresh the model to revert UI to database values after rollback
$this->storage->refresh();
return handleError($e, $this);
}
}

View file

@ -35,7 +35,7 @@ public function submit()
'personal_team' => false,
]);
auth()->user()->teams()->attach($team, ['role' => 'admin']);
refreshSession();
refreshSession($team);
return redirect()->route('team.index');
} catch (\Throwable $e) {

View file

@ -45,9 +45,16 @@ private function generateInviteLink(bool $sendEmail = false)
try {
$this->authorize('manageInvitations', currentTeam());
$this->validate();
if (auth()->user()->role() === 'admin' && $this->role === 'owner') {
// Prevent privilege escalation: users cannot invite someone with higher privileges
$userRole = auth()->user()->role();
if ($userRole === 'member' && in_array($this->role, ['admin', 'owner'])) {
throw new \Exception('Members cannot invite admins or owners.');
}
if ($userRole === 'admin' && $this->role === 'owner') {
throw new \Exception('Admins cannot invite owners.');
}
$this->email = strtolower($this->email);
$member_emails = currentTeam()->members()->get()->pluck('email');

View file

@ -61,6 +61,10 @@ private function getAllActiveContainers()
public function updatedSelectedUuid()
{
if ($this->selected_uuid === 'default') {
// When cleared to default, do nothing (no error message)
return;
}
$this->connectToContainer();
}

View file

@ -182,6 +182,21 @@ protected static function booted()
]);
$application->compose_parsing_version = self::$parserVersion;
$application->save();
// Add default NIXPACKS_NODE_VERSION environment variable for Nixpacks applications
if ($application->build_pack === 'nixpacks') {
EnvironmentVariable::create([
'key' => 'NIXPACKS_NODE_VERSION',
'value' => '22',
'is_multiline' => false,
'is_literal' => false,
'is_buildtime' => true,
'is_runtime' => false,
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $application->id,
]);
}
});
static::forceDeleting(function ($application) {
$application->update(['fqdn' => null]);
@ -739,9 +754,9 @@ public function environment_variables()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', false)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@ -767,9 +782,9 @@ public function environment_variables_preview()
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->where('is_preview', true)
->orderByRaw("
CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
@ -988,29 +1003,30 @@ public function dirOnServer()
public function setGitImportSettings(string $deployment_uuid, string $git_clone_command, bool $public = false)
{
$baseDir = $this->generateBaseDir($deployment_uuid);
$escapedBaseDir = escapeshellarg($baseDir);
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
if ($this->git_commit_sha !== 'HEAD') {
// If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1
if ($isShallowCloneEnabled) {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git fetch --depth=1 origin {$this->git_commit_sha} && git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
} else {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git -c advice.detachedHead=false checkout {$this->git_commit_sha} >/dev/null 2>&1";
}
}
if ($this->settings->is_git_submodules_enabled) {
// Check if .gitmodules file exists before running submodule commands
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && if [ -f .gitmodules ]; then";
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && if [ -f .gitmodules ]; then";
if ($public) {
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&";
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$escapedBaseDir}/.gitmodules || true &&";
}
// Add shallow submodules flag if shallow clone is enabled
$submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
$git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
}
if ($this->settings->is_git_lfs_enabled) {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
}
return $git_clone_command;
@ -1048,18 +1064,24 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
$source_html_url_scheme = $url['scheme'];
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
$escapedCustomRepository = escapeshellarg($customRepository);
if ($this->source->is_public) {
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
$fullRepoUrl = "{$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$this->source->html_url}/{$customRepository}";
$base_command = "{$base_command} {$escapedRepoUrl}";
} else {
$github_access_token = generateGithubInstallationToken($this->source);
if ($exec_in_docker) {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
} else {
$base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$repoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}";
$escapedRepoUrl = escapeshellarg($repoUrl);
$base_command = "{$base_command} {$escapedRepoUrl}";
$fullRepoUrl = $repoUrl;
}
}
@ -1084,7 +1106,10 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
throw new RuntimeException('Private key not found. Please add a private key to the application and try again.');
}
$private_key = base64_encode($private_key);
$base_comamnd = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} {$customRepository}";
// When used with executeInDocker (which uses bash -c '...'), we need to escape for bash context
// Replace ' with '\'' to safely escape within single-quoted bash strings
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
@ -1101,9 +1126,9 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_comamnd));
$commands->push(executeInDocker($deployment_uuid, $base_command));
} else {
$commands->push($base_comamnd);
$commands->push($base_command);
}
return [
@ -1115,7 +1140,8 @@ public function generateGitLsRemoteCommands(string $deployment_uuid, bool $exec_
if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository;
$base_command = "{$base_command} {$customRepository}";
$escapedCustomRepository = escapeshellarg($customRepository);
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
@ -1257,7 +1283,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@ -1265,14 +1291,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
}
}
@ -1290,7 +1316,8 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
}
if ($this->deploymentType() === 'other') {
$fullRepoUrl = $customRepository;
$git_clone_command = "{$git_clone_command} {$customRepository} {$baseDir}";
$escapedCustomRepository = escapeshellarg($customRepository);
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true);
if ($pull_request_id !== 0) {
@ -1301,7 +1328,7 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
@ -1309,14 +1336,14 @@ public function generateGitImportCommands(string $deployment_uuid, int $pull_req
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
} else {
$commands->push("echo 'Checking out $branch'");
}
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" ".$this->buildGitCheckoutCommand($commit);
}
}

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