2024-07-02 14:12:04 +00:00
< ? php
namespace App\Http\Controllers\Api ;
use App\Actions\Service\RestartService ;
use App\Actions\Service\StartService ;
use App\Actions\Service\StopService ;
use App\Http\Controllers\Controller ;
use App\Jobs\DeleteResourceJob ;
use App\Models\EnvironmentVariable ;
use App\Models\Project ;
use App\Models\Server ;
use App\Models\Service ;
use Illuminate\Http\Request ;
2026-01-11 21:19:09 +00:00
use Illuminate\Support\Facades\Validator ;
2024-07-09 11:30:13 +00:00
use OpenApi\Attributes as OA ;
2025-03-19 08:22:34 +00:00
use Symfony\Component\Yaml\Yaml ;
2024-07-02 14:12:04 +00:00
class ServicesController extends Controller
{
private function removeSensitiveData ( $service )
{
2024-07-04 11:45:06 +00:00
$service -> makeHidden ([
'id' ,
2024-12-17 09:38:32 +00:00
'resourceable' ,
'resourceable_id' ,
'resourceable_type' ,
2024-07-04 11:45:06 +00:00
]);
2024-12-09 10:10:35 +00:00
if ( request () -> attributes -> get ( 'can_read_sensitive' , false ) === false ) {
$service -> makeHidden ([
'docker_compose_raw' ,
'docker_compose' ,
2024-12-12 12:20:13 +00:00
'value' ,
'real_value' ,
2024-12-09 10:10:35 +00:00
]);
2024-07-02 14:12:04 +00:00
}
return serializeApiResponse ( $service );
}
2026-01-13 19:04:44 +00:00
private function applyServiceUrls ( Service $service , array $urlsArray , string $teamId , bool $forceDomainOverride = false ) : ? array
2026-01-11 21:19:09 +00:00
{
$errors = [];
2026-01-13 18:25:58 +00:00
$conflicts = [];
2026-01-11 21:19:09 +00:00
2026-01-13 19:04:44 +00:00
$urls = collect ( $urlsArray ) -> flatMap ( function ( $item ) {
$urlValue = data_get ( $item , 'url' );
if ( blank ( $urlValue )) {
return [];
}
return str ( $urlValue ) -> replaceStart ( ',' , '' ) -> replaceEnd ( ',' , '' ) -> trim () -> explode ( ',' ) -> map ( fn ( $url ) => trim ( $url )) -> filter ();
});
$urls = $urls -> map ( function ( $url ) use ( & $errors ) {
if ( ! filter_var ( $url , FILTER_VALIDATE_URL )) {
$errors [] = " Invalid URL: { $url } " ;
return $url ;
}
$scheme = parse_url ( $url , PHP_URL_SCHEME ) ? ? '' ;
if ( ! in_array ( strtolower ( $scheme ), [ 'http' , 'https' ])) {
$errors [] = " Invalid URL scheme: { $scheme } for URL: { $url } . Only http and https are supported. " ;
}
return $url ;
});
$duplicates = $urls -> duplicates () -> unique () -> values ();
if ( $duplicates -> isNotEmpty () && ! $forceDomainOverride ) {
2026-01-14 13:42:35 +00:00
$errors [] = 'The current request contains conflicting URLs across containers: ' . implode ( ', ' , $duplicates -> toArray ()) . '. Use force_domain_override=true to proceed.' ;
2026-01-13 19:04:44 +00:00
}
if ( count ( $errors ) > 0 ) {
return [ 'errors' => $errors ];
}
collect ( $urlsArray ) -> each ( function ( $item ) use ( $service , $teamId , $forceDomainOverride , & $errors , & $conflicts ) {
$name = data_get ( $item , 'name' );
$containerUrls = data_get ( $item , 'url' );
2026-01-11 21:19:09 +00:00
if ( blank ( $name )) {
$errors [] = 'Service container name is required to apply URLs.' ;
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
$application = $service -> applications () -> where ( 'name' , $name ) -> first ();
if ( ! $application ) {
$errors [] = " Service container with ' { $name } ' not found. " ;
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
2026-01-13 19:04:44 +00:00
if ( filled ( $containerUrls )) {
$containerUrls = str ( $containerUrls ) -> replaceStart ( ',' , '' ) -> replaceEnd ( ',' , '' ) -> trim ();
$containerUrls = str ( $containerUrls ) -> explode ( ',' ) -> map ( fn ( $url ) => str ( trim ( $url )) -> lower ());
2026-01-13 18:25:58 +00:00
2026-01-13 19:04:44 +00:00
$result = checkIfDomainIsAlreadyUsedViaAPI ( $containerUrls , $teamId , $application -> uuid );
2026-01-13 18:25:58 +00:00
if ( isset ( $result [ 'error' ])) {
$errors [] = $result [ 'error' ];
2026-01-13 19:04:44 +00:00
return ;
2026-01-13 18:25:58 +00:00
}
if ( $result [ 'hasConflicts' ] && ! $forceDomainOverride ) {
$conflicts = array_merge ( $conflicts , $result [ 'conflicts' ]);
2026-01-13 19:04:44 +00:00
return ;
2026-01-11 21:19:09 +00:00
}
2026-01-13 18:25:58 +00:00
2026-01-13 19:04:44 +00:00
$containerUrls = $containerUrls -> filter ( fn ( $u ) => filled ( $u )) -> unique () -> implode ( ',' );
2026-01-11 21:19:09 +00:00
} else {
2026-01-13 19:04:44 +00:00
$containerUrls = null ;
2026-01-11 21:19:09 +00:00
}
2026-01-13 19:04:44 +00:00
$application -> fqdn = $containerUrls ;
2026-01-11 21:19:09 +00:00
$application -> save ();
2026-01-13 19:04:44 +00:00
});
2026-01-11 21:19:09 +00:00
if ( ! empty ( $errors )) {
return [ 'errors' => $errors ];
}
2026-01-13 18:25:58 +00:00
if ( ! empty ( $conflicts )) {
return [
'conflicts' => $conflicts ,
'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ,
];
}
2026-01-11 21:19:09 +00:00
return null ;
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'List' ,
description : 'List all services.' ,
path : '/services' ,
2024-09-04 08:09:10 +00:00
operationId : 'list-services' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
responses : [
new OA\Response (
response : 200 ,
description : 'Get all services' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/Service' )
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function services ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$projects = Project :: where ( 'team_id' , $teamId ) -> get ();
2024-07-02 14:12:04 +00:00
$services = collect ();
foreach ( $projects as $project ) {
$services -> push ( $project -> services () -> get ());
}
foreach ( $services as $service ) {
$service = $this -> removeSensitiveData ( $service );
}
2024-07-04 11:45:06 +00:00
return response () -> json ( $services -> flatten ());
2024-07-02 14:12:04 +00:00
}
2024-07-09 11:30:13 +00:00
#[OA\Post(
2025-03-21 10:31:17 +00:00
summary : 'Create service' ,
description : 'Create a one-click / custom service' ,
path : '/services' ,
operationId : 'create-service' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
requestBody : new OA\RequestBody (
required : true ,
content : new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
2025-04-03 14:02:59 +00:00
required : [ 'server_uuid' , 'project_uuid' , 'environment_name' , 'environment_uuid' ],
2024-07-09 11:30:13 +00:00
properties : [
2026-01-10 21:29:11 +00:00
'type' => [ 'description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)' , 'type' => 'string' ],
2024-07-09 11:59:54 +00:00
'name' => [ 'type' => 'string' , 'maxLength' => 255 , 'description' => 'Name of the service.' ],
'description' => [ 'type' => 'string' , 'nullable' => true , 'description' => 'Description of the service.' ],
'project_uuid' => [ 'type' => 'string' , 'description' => 'Project UUID.' ],
2024-12-17 12:42:16 +00:00
'environment_name' => [ 'type' => 'string' , 'description' => 'Environment name. You need to provide at least one of environment_name or environment_uuid.' ],
'environment_uuid' => [ 'type' => 'string' , 'description' => 'Environment UUID. You need to provide at least one of environment_name or environment_uuid.' ],
2024-07-09 11:59:54 +00:00
'server_uuid' => [ 'type' => 'string' , 'description' => 'Server UUID.' ],
'destination_uuid' => [ 'type' => 'string' , 'description' => 'Destination UUID. Required if server has multiple destinations.' ],
'instant_deploy' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Start the service immediately after creation.' ],
2026-01-11 17:26:11 +00:00
'docker_compose_raw' => [ 'type' => 'string' , 'description' => 'The base64 encoded Docker Compose content.' ],
2026-01-11 21:19:09 +00:00
'urls' => [
'type' => 'array' ,
'description' => 'Array of URLs to be applied to containers of a service.' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name as defined in docker-compose.' ],
2026-01-14 13:50:48 +00:00
'url' => [ 'type' => 'string' , 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").' ],
2026-01-11 21:19:09 +00:00
],
),
],
2026-01-13 18:25:58 +00:00
'force_domain_override' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Force domain override even if conflicts are detected.' ],
2024-07-09 11:30:13 +00:00
],
),
),
),
responses : [
new OA\Response (
response : 201 ,
2025-03-19 08:22:34 +00:00
description : 'Service created successfully.' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
2024-07-09 11:59:54 +00:00
'uuid' => [ 'type' => 'string' , 'description' => 'Service UUID.' ],
'domains' => [ 'type' => 'array' , 'items' => [ 'type' => 'string' ], 'description' => 'Service domains.' ],
2024-07-09 11:30:13 +00:00
]
)
),
]
),
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
2026-01-13 18:25:58 +00:00
new OA\Response (
response : 409 ,
description : 'Domain conflicts detected.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ],
'warning' => [ 'type' => 'string' , 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ],
'conflicts' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'domain' => [ 'type' => 'string' , 'example' => 'example.com' ],
'resource_name' => [ 'type' => 'string' , 'example' => 'My Application' ],
'resource_uuid' => [ 'type' => 'string' , 'nullable' => true , 'example' => 'abc123-def456' ],
'resource_type' => [ 'type' => 'string' , 'enum' => [ 'application' , 'service' , 'instance' ], 'example' => 'application' ],
'message' => [ 'type' => 'string' , 'example' => 'Domain example.com is already in use by application \'My Application\'' ],
]
),
],
]
)
),
]
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-07-09 11:30:13 +00:00
]
)]
2025-03-21 10:31:17 +00:00
public function create_service ( Request $request )
2024-07-02 14:12:04 +00:00
{
2026-01-13 18:25:58 +00:00
$allowedFields = [ 'type' , 'name' , 'description' , 'project_uuid' , 'environment_name' , 'environment_uuid' , 'server_uuid' , 'destination_uuid' , 'instant_deploy' , 'docker_compose_raw' , 'urls' , 'force_domain_override' ];
2024-07-02 14:12:04 +00:00
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'create' , Service :: class );
2024-07-02 14:12:04 +00:00
$return = validateIncomingRequest ( $request );
2025-01-07 14:31:43 +00:00
if ( $return instanceof \Illuminate\Http\JsonResponse ) {
2024-07-02 14:12:04 +00:00
return $return ;
}
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-03-21 10:31:17 +00:00
'type' => 'string|required_without:docker_compose_raw' ,
'docker_compose_raw' => 'string|required_without:type' ,
2024-07-02 14:12:04 +00:00
'project_uuid' => 'string|required' ,
2024-12-17 12:42:16 +00:00
'environment_name' => 'string|nullable' ,
'environment_uuid' => 'string|nullable' ,
2024-07-02 14:12:04 +00:00
'server_uuid' => 'string|required' ,
2025-04-03 14:02:59 +00:00
'destination_uuid' => 'string|nullable' ,
2024-07-02 14:12:04 +00:00
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2024-07-02 14:12:04 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
2025-01-07 14:31:43 +00:00
if ( $validator -> fails () || ! empty ( $extraFields )) {
2024-07-02 14:12:04 +00:00
$errors = $validator -> errors ();
2025-01-07 14:31:43 +00:00
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
2024-07-02 14:12:04 +00:00
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
2026-01-10 21:29:11 +00:00
if ( filled ( $request -> type ) && filled ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'You cannot provide both service type and docker_compose_raw. Use one or the other.' ,
], 422 );
}
2024-12-17 12:42:16 +00:00
$environmentUuid = $request -> environment_uuid ;
$environmentName = $request -> environment_name ;
if ( blank ( $environmentUuid ) && blank ( $environmentName )) {
return response () -> json ([ 'message' => 'You need to provide at least one of environment_name or environment_uuid.' ], 422 );
}
2024-07-02 14:12:04 +00:00
$serverUuid = $request -> server_uuid ;
$instantDeploy = $request -> instant_deploy ? ? false ;
if ( $request -> is_public && ! $request -> public_port ) {
$request -> offsetSet ( 'is_public' , false );
}
$project = Project :: whereTeamId ( $teamId ) -> whereUuid ( $request -> project_uuid ) -> first ();
if ( ! $project ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Project not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2024-12-17 12:42:16 +00:00
$environment = $project -> environments () -> where ( 'name' , $environmentName ) -> first ();
if ( ! $environment ) {
$environment = $project -> environments () -> where ( 'uuid' , $environmentUuid ) -> first ();
}
2024-07-02 14:12:04 +00:00
if ( ! $environment ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Environment not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
$server = Server :: whereTeamId ( $teamId ) -> whereUuid ( $serverUuid ) -> first ();
if ( ! $server ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
$destinations = $server -> destinations ();
if ( $destinations -> count () == 0 ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server has no destinations.' ], 400 );
2024-07-02 14:12:04 +00:00
}
if ( $destinations -> count () > 1 && ! $request -> has ( 'destination_uuid' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Server has multiple destinations and you do not set destination_uuid.' ], 400 );
2024-07-02 14:12:04 +00:00
}
$destination = $destinations -> first ();
$services = get_service_templates ();
$serviceKeys = $services -> keys ();
if ( $serviceKeys -> contains ( $request -> type )) {
$oneClickServiceName = $request -> type ;
$oneClickService = data_get ( $services , " $oneClickServiceName .compose " );
$oneClickDotEnvs = data_get ( $services , " $oneClickServiceName .envs " , null );
if ( $oneClickDotEnvs ) {
$oneClickDotEnvs = str ( base64_decode ( $oneClickDotEnvs )) -> split ( '/\r\n|\r|\n/' ) -> filter ( function ( $value ) {
2025-01-07 14:31:43 +00:00
return ! empty ( $value );
2024-07-02 14:12:04 +00:00
});
}
if ( $oneClickService ) {
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
$dockerComposeRaw = base64_decode ( $oneClickService );
// Validate for command injection BEFORE creating service
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-10-15 20:07:39 +00:00
$servicePayload = [
2024-07-02 14:12:04 +00:00
'name' => " $oneClickServiceName - " . str () -> random ( 10 ),
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
'docker_compose_raw' => $dockerComposeRaw ,
2024-07-02 14:12:04 +00:00
'environment_id' => $environment -> id ,
'service_type' => $oneClickServiceName ,
'server_id' => $server -> id ,
'destination_id' => $destination -> id ,
'destination_type' => $destination -> getMorphClass (),
];
2025-11-28 09:29:08 +00:00
if ( in_array ( $oneClickServiceName , NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK )) {
2025-10-15 20:07:39 +00:00
data_set ( $servicePayload , 'connect_to_docker_network' , true );
2024-07-02 14:12:04 +00:00
}
2025-10-15 20:07:39 +00:00
$service = Service :: create ( $servicePayload );
2026-01-13 16:26:51 +00:00
$service -> name = $request -> name ? ? " $oneClickServiceName - " . $service -> uuid ;
$service -> description = $request -> description ;
2024-07-02 14:12:04 +00:00
$service -> save ();
if ( $oneClickDotEnvs ? -> count () > 0 ) {
$oneClickDotEnvs -> each ( function ( $value ) use ( $service ) {
$key = str () -> before ( $value , '=' );
$value = str ( str () -> after ( $value , '=' ));
$generatedValue = $value ;
if ( $value -> contains ( 'SERVICE_' )) {
$command = $value -> after ( 'SERVICE_' ) -> beforeLast ( '_' );
$generatedValue = generateEnvValue ( $command -> value (), $service );
}
2025-01-07 14:31:43 +00:00
EnvironmentVariable :: create ([
2024-07-02 14:12:04 +00:00
'key' => $key ,
'value' => $generatedValue ,
2024-12-17 09:38:32 +00:00
'resourceable_id' => $service -> id ,
'resourceable_type' => $service -> getMorphClass (),
2024-07-02 14:12:04 +00:00
'is_preview' => false ,
]);
});
}
$service -> parse ( isNew : true );
2025-11-28 15:33:27 +00:00
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites ( $service );
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
$service -> delete ();
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2026-01-11 21:19:09 +00:00
}
}
2024-07-02 14:12:04 +00:00
if ( $instantDeploy ) {
StartService :: dispatch ( $service );
}
return response () -> json ([
2024-07-04 11:45:06 +00:00
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
]) -> setStatusCode ( 201 );
2024-07-02 14:12:04 +00:00
}
2025-03-21 10:31:17 +00:00
return response () -> json ([ 'message' => 'Service not found.' , 'valid_service_types' => $serviceKeys ], 404 );
} elseif ( filled ( $request -> docker_compose_raw )) {
2026-01-13 18:25:58 +00:00
$allowedFields = [ 'name' , 'description' , 'project_uuid' , 'environment_name' , 'environment_uuid' , 'server_uuid' , 'destination_uuid' , 'instant_deploy' , 'docker_compose_raw' , 'connect_to_docker_network' , 'urls' , 'force_domain_override' ];
2025-08-17 17:45:12 +00:00
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-08-17 17:45:12 +00:00
'project_uuid' => 'string|required' ,
'environment_name' => 'string|nullable' ,
'environment_uuid' => 'string|nullable' ,
'server_uuid' => 'string|required' ,
'destination_uuid' => 'string' ,
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
'connect_to_docker_network' => 'boolean' ,
'docker_compose_raw' => 'string|required' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2025-08-17 17:45:12 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
$environmentUuid = $request -> environment_uuid ;
$environmentName = $request -> environment_name ;
if ( blank ( $environmentUuid ) && blank ( $environmentName )) {
return response () -> json ([ 'message' => 'You need to provide at least one of environment_name or environment_uuid.' ], 422 );
}
$serverUuid = $request -> server_uuid ;
$projectUuid = $request -> project_uuid ;
$project = Project :: whereTeamId ( $teamId ) -> whereUuid ( $projectUuid ) -> first ();
if ( ! $project ) {
return response () -> json ([ 'message' => 'Project not found.' ], 404 );
}
$environment = $project -> environments () -> where ( 'name' , $environmentName ) -> first ();
if ( ! $environment ) {
$environment = $project -> environments () -> where ( 'uuid' , $environmentUuid ) -> first ();
}
if ( ! $environment ) {
return response () -> json ([ 'message' => 'Environment not found.' ], 404 );
}
$server = Server :: whereTeamId ( $teamId ) -> whereUuid ( $serverUuid ) -> first ();
if ( ! $server ) {
return response () -> json ([ 'message' => 'Server not found.' ], 404 );
}
$destinations = $server -> destinations ();
if ( $destinations -> count () == 0 ) {
return response () -> json ([ 'message' => 'Server has no destinations.' ], 400 );
}
if ( $destinations -> count () > 1 && ! $request -> has ( 'destination_uuid' )) {
return response () -> json ([ 'message' => 'Server has multiple destinations and you do not set destination_uuid.' ], 400 );
}
$destination = $destinations -> first ();
if ( ! isBase64Encoded ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerComposeRaw = base64_decode ( $request -> docker_compose_raw );
2026-01-13 15:53:11 +00:00
if ( mb_detect_encoding ( $dockerComposeRaw , 'UTF-8' , true ) === false ) {
2025-08-17 17:45:12 +00:00
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerCompose = base64_decode ( $request -> docker_compose_raw );
$dockerComposeRaw = Yaml :: dump ( Yaml :: parse ( $dockerCompose ), 10 , 2 , Yaml :: DUMP_MULTI_LINE_LITERAL_BLOCK );
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
// Validate for command injection BEFORE saving to database
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-08-17 17:45:12 +00:00
$connectToDockerNetwork = $request -> connect_to_docker_network ? ? false ;
$instantDeploy = $request -> instant_deploy ? ? false ;
2024-07-02 14:12:04 +00:00
2025-03-21 10:31:17 +00:00
$service = new Service ;
2025-08-17 17:45:12 +00:00
$service -> name = $request -> name ? ? 'service-' . str () -> random ( 10 );
$service -> description = $request -> description ;
$service -> docker_compose_raw = $dockerComposeRaw ;
$service -> environment_id = $environment -> id ;
$service -> server_id = $server -> id ;
$service -> destination_id = $destination -> id ;
$service -> destination_type = $destination -> getMorphClass ();
$service -> connect_to_docker_network = $connectToDockerNetwork ;
$service -> save ();
$service -> parse ( isNew : true );
2025-03-14 14:26:48 +00:00
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
$service -> delete ();
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2025-08-17 17:45:12 +00:00
}
2026-01-11 21:19:09 +00:00
}
2025-08-17 17:45:12 +00:00
2026-01-11 21:19:09 +00:00
if ( $instantDeploy ) {
StartService :: dispatch ( $service );
}
2025-08-17 17:45:12 +00:00
return response () -> json ([
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
2025-08-17 17:45:12 +00:00
]) -> setStatusCode ( 201 );
2026-01-10 21:29:11 +00:00
} elseif ( filled ( $request -> type )) {
return response () -> json ([
'message' => 'Invalid service type.' ,
'valid_service_types' => $serviceKeys ,
], 404 );
2025-03-14 14:26:48 +00:00
}
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'Get' ,
description : 'Get service by UUID.' ,
path : '/services/{uuid}' ,
2024-09-04 08:09:10 +00:00
operationId : 'get-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter ( name : 'uuid' , in : 'path' , required : true , description : 'Service UUID' , schema : new OA\Schema ( type : 'string' )),
],
responses : [
new OA\Response (
response : 200 ,
2024-09-09 16:38:40 +00:00
description : 'Get a service by UUID.' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
ref : '#/components/schemas/Service'
)
),
]
),
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' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function service_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
if ( ! $request -> uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'view' , $service );
2024-08-07 07:20:55 +00:00
$service = $service -> load ([ 'applications' , 'databases' ]);
2024-07-03 11:13:38 +00:00
return response () -> json ( $this -> removeSensitiveData ( $service ));
2024-07-02 14:12:04 +00:00
}
2024-07-09 11:30:13 +00:00
#[OA\Delete(
summary : 'Delete' ,
description : 'Delete service by UUID.' ,
path : '/services/{uuid}' ,
2024-09-04 08:09:10 +00:00
operationId : 'delete-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter ( name : 'uuid' , in : 'path' , required : true , description : 'Service UUID' , schema : new OA\Schema ( type : 'string' )),
2024-10-01 07:02:16 +00:00
new OA\Parameter ( name : 'delete_configurations' , in : 'query' , required : false , description : 'Delete configurations.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'delete_volumes' , in : 'query' , required : false , description : 'Delete volumes.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'docker_cleanup' , in : 'query' , required : false , description : 'Run docker cleanup.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
new OA\Parameter ( name : 'delete_connected_networks' , in : 'query' , required : false , description : 'Delete connected networks.' , schema : new OA\Schema ( type : 'boolean' , default : true )),
2024-07-09 11:30:13 +00:00
],
responses : [
new OA\Response (
response : 200 ,
2024-09-09 16:38:40 +00:00
description : 'Delete a service by UUID' ,
2024-07-09 11:30:13 +00:00
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service deletion request queued.' ],
],
)
),
]
),
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' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function delete_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
if ( ! $request -> uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2024-10-01 07:02:16 +00:00
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'delete' , $service );
2024-10-01 07:02:16 +00:00
DeleteResourceJob :: dispatch (
resource : $service ,
2025-10-26 15:25:44 +00:00
deleteVolumes : $request -> boolean ( 'delete_volumes' , true ),
deleteConnectedNetworks : $request -> boolean ( 'delete_connected_networks' , true ),
deleteConfigurations : $request -> boolean ( 'delete_configurations' , true ),
dockerCleanup : $request -> boolean ( 'docker_cleanup' , true )
2024-10-01 07:02:16 +00:00
);
2024-07-02 14:12:04 +00:00
return response () -> json ([
'message' => 'Service deletion request queued.' ,
]);
}
2025-03-20 06:28:28 +00:00
#[OA\Patch(
summary : 'Update' ,
description : 'Update service by UUID.' ,
path : '/services/{uuid}' ,
operationId : 'update-service-by-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
2025-04-09 16:52:12 +00:00
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
2025-03-20 06:28:28 +00:00
requestBody : new OA\RequestBody (
description : 'Service updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name.' ],
'description' => [ 'type' => 'string' , 'description' => 'The service description.' ],
'project_uuid' => [ 'type' => 'string' , 'description' => 'The project UUID.' ],
'environment_name' => [ 'type' => 'string' , 'description' => 'The environment name.' ],
'environment_uuid' => [ 'type' => 'string' , 'description' => 'The environment UUID.' ],
'server_uuid' => [ 'type' => 'string' , 'description' => 'The server UUID.' ],
'destination_uuid' => [ 'type' => 'string' , 'description' => 'The destination UUID.' ],
'instant_deploy' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the service should be deployed instantly.' ],
'connect_to_docker_network' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Connect the service to the predefined docker network.' ],
2026-01-11 17:26:11 +00:00
'docker_compose_raw' => [ 'type' => 'string' , 'description' => 'The base64 encoded Docker Compose content.' ],
2026-01-11 21:19:09 +00:00
'urls' => [
'type' => 'array' ,
'description' => 'Array of URLs to be applied to containers of a service.' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'name' => [ 'type' => 'string' , 'description' => 'The service name as defined in docker-compose.' ],
2026-01-14 13:50:48 +00:00
'url' => [ 'type' => 'string' , 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").' ],
2026-01-11 21:19:09 +00:00
],
),
],
2026-01-13 18:25:58 +00:00
'force_domain_override' => [ 'type' => 'boolean' , 'default' => false , 'description' => 'Force domain override even if conflicts are detected.' ],
2025-03-20 06:28:28 +00:00
],
)
),
]
),
responses : [
new OA\Response (
response : 200 ,
description : 'Service updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'uuid' => [ 'type' => 'string' , 'description' => 'Service UUID.' ],
'domains' => [ 'type' => 'array' , 'items' => [ 'type' => 'string' ], 'description' => 'Service domains.' ],
]
)
),
]
),
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' ,
),
2026-01-13 18:25:58 +00:00
new OA\Response (
response : 409 ,
description : 'Domain conflicts detected.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ],
'warning' => [ 'type' => 'string' , 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' ],
'conflicts' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'domain' => [ 'type' => 'string' , 'example' => 'example.com' ],
'resource_name' => [ 'type' => 'string' , 'example' => 'My Application' ],
'resource_uuid' => [ 'type' => 'string' , 'nullable' => true , 'example' => 'abc123-def456' ],
'resource_type' => [ 'type' => 'string' , 'enum' => [ 'application' , 'service' , 'instance' ], 'example' => 'application' ],
'message' => [ 'type' => 'string' , 'example' => 'Domain example.com is already in use by application \'My Application\'' ],
]
),
],
]
)
),
]
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2025-03-20 06:28:28 +00:00
]
)]
public function update_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$return = validateIncomingRequest ( $request );
if ( $return instanceof \Illuminate\Http\JsonResponse ) {
return $return ;
}
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
if ( ! $service ) {
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'update' , $service );
2026-01-13 18:25:58 +00:00
$allowedFields = [ 'name' , 'description' , 'instant_deploy' , 'docker_compose_raw' , 'connect_to_docker_network' , 'urls' , 'force_domain_override' ];
2025-03-20 06:28:28 +00:00
2026-01-11 21:19:09 +00:00
$validationRules = [
2025-03-20 06:28:28 +00:00
'name' => 'string|max:255' ,
'description' => 'string|nullable' ,
'instant_deploy' => 'boolean' ,
'connect_to_docker_network' => 'boolean' ,
2025-07-30 19:59:35 +00:00
'docker_compose_raw' => 'string|nullable' ,
2026-01-11 21:19:09 +00:00
'urls' => 'array|nullable' ,
'urls.*' => 'array:name,url' ,
'urls.*.name' => 'string|required' ,
'urls.*.url' => 'string|nullable' ,
2026-01-13 18:25:58 +00:00
'force_domain_override' => 'boolean' ,
2026-01-11 21:19:09 +00:00
];
$validationMessages = [
'urls.*.array' => 'An item in the urls array has invalid fields. Only name and url fields are supported.' ,
];
$validator = Validator :: make ( $request -> all (), $validationRules , $validationMessages );
2025-03-20 06:28:28 +00:00
$extraFields = array_diff ( array_keys ( $request -> all ()), $allowedFields );
if ( $validator -> fails () || ! empty ( $extraFields )) {
$errors = $validator -> errors ();
if ( ! empty ( $extraFields )) {
foreach ( $extraFields as $field ) {
$errors -> add ( $field , 'This field is not allowed.' );
}
}
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $errors ,
], 422 );
}
2025-07-30 19:59:35 +00:00
if ( $request -> has ( 'docker_compose_raw' )) {
if ( ! isBase64Encoded ( $request -> docker_compose_raw )) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerComposeRaw = base64_decode ( $request -> docker_compose_raw );
2026-01-13 15:53:11 +00:00
if ( mb_detect_encoding ( $dockerComposeRaw , 'UTF-8' , true ) === false ) {
2025-07-30 19:59:35 +00:00
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.' ,
],
], 422 );
}
$dockerCompose = base64_decode ( $request -> docker_compose_raw );
$dockerComposeRaw = Yaml :: dump ( Yaml :: parse ( $dockerCompose ), 10 , 2 , Yaml :: DUMP_MULTI_LINE_LITERAL_BLOCK );
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
// Validate for command injection BEFORE saving to database
2025-10-15 20:07:39 +00:00
try {
validateDockerComposeForInjection ( $dockerComposeRaw );
} catch ( \Exception $e ) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => [
'docker_compose_raw' => $e -> getMessage (),
],
], 422 );
}
fix: prevent command injection in Docker Compose parsing - add pre-save validation
This commit addresses a critical security issue where malicious Docker Compose
data was being saved to the database before validation occurred.
Problem:
- Service models were saved to database first
- Validation ran afterwards during parse()
- Malicious data persisted even when validation failed
- User saw error but damage was already done
Solution:
1. Created validateDockerComposeForInjection() to validate YAML before save
2. Added pre-save validation to all Service creation/update points:
- Livewire: DockerCompose.php, StackForm.php
- API: ServicesController.php (create, update, one-click)
3. Validates service names and volume paths (string + array formats)
4. Blocks shell metacharacters: backticks, $(), |, ;, &, >, <, newlines
Security fixes:
- Volume source paths (string format) - validated before save
- Volume source paths (array format) - validated before save
- Service names - validated before save
- Environment variable patterns - safe ${VAR} allowed, ${VAR:-$(cmd)} blocked
Testing:
- 60 security tests pass (176 assertions)
- PreSaveValidationTest.php: 15 tests for pre-save validation
- ValidateShellSafePathTest.php: 15 tests for core validation
- VolumeSecurityTest.php: 15 tests for volume parsing
- ServiceNameSecurityTest.php: 15 tests for service names
Related commits:
- Previous: Added validation during parse() phase
- This commit: Moves validation before database save
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-15 19:46:26 +00:00
2025-07-30 19:59:35 +00:00
$service -> docker_compose_raw = $dockerComposeRaw ;
2025-03-20 06:28:28 +00:00
}
2025-08-17 17:45:12 +00:00
if ( $request -> has ( 'name' )) {
$service -> name = $request -> name ;
}
if ( $request -> has ( 'description' )) {
$service -> description = $request -> description ;
}
if ( $request -> has ( 'connect_to_docker_network' )) {
$service -> connect_to_docker_network = $request -> connect_to_docker_network ;
}
2025-03-20 06:28:28 +00:00
$service -> save ();
2025-08-17 17:45:12 +00:00
2025-03-20 06:28:28 +00:00
$service -> parse ();
2026-01-11 21:19:09 +00:00
if ( $request -> has ( 'urls' ) && is_array ( $request -> urls )) {
2026-01-13 18:25:58 +00:00
$urlResult = $this -> applyServiceUrls ( $service , $request -> urls , $teamId , $request -> boolean ( 'force_domain_override' ));
2026-01-11 21:19:09 +00:00
if ( $urlResult !== null ) {
2026-01-13 18:25:58 +00:00
if ( isset ( $urlResult [ 'errors' ])) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $urlResult [ 'errors' ],
], 422 );
}
if ( isset ( $urlResult [ 'conflicts' ])) {
return response () -> json ([
'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.' ,
'conflicts' => $urlResult [ 'conflicts' ],
'warning' => $urlResult [ 'warning' ],
], 409 );
}
2025-03-20 06:28:28 +00:00
}
2026-01-11 21:19:09 +00:00
}
2025-03-20 06:28:28 +00:00
2026-01-11 21:19:09 +00:00
if ( $request -> instant_deploy ) {
StartService :: dispatch ( $service );
}
2025-03-20 06:28:28 +00:00
2025-08-17 17:45:12 +00:00
return response () -> json ([
2025-03-20 06:28:28 +00:00
'uuid' => $service -> uuid ,
2026-01-11 21:19:09 +00:00
'domains' => $service -> applications () -> pluck ( 'fqdn' ) -> filter () -> sort () -> values (),
2025-08-17 17:45:12 +00:00
]) -> setStatusCode ( 200 );
2025-03-20 06:28:28 +00:00
}
2024-09-05 20:54:20 +00:00
#[OA\Get(
summary : 'List Envs' ,
description : 'List all envs by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'list-envs-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'All environment variables by service UUID.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/EnvironmentVariable' )
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function envs ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-06 08:48:47 +00:00
$envs = $service -> environment_variables -> map ( function ( $env ) {
2024-09-05 20:54:20 +00:00
$env -> makeHidden ([
'application_id' ,
'standalone_clickhouse_id' ,
'standalone_dragonfly_id' ,
'standalone_keydb_id' ,
'standalone_mariadb_id' ,
'standalone_mongodb_id' ,
'standalone_mysql_id' ,
'standalone_postgresql_id' ,
'standalone_redis_id' ,
]);
2024-10-31 17:20:11 +00:00
return $this -> removeSensitiveData ( $env );
2024-09-05 20:54:20 +00:00
});
2024-09-06 08:48:47 +00:00
return response () -> json ( $envs );
2024-09-05 20:54:20 +00:00
}
#[OA\Patch(
summary : 'Update Env' ,
description : 'Update env by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'update-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Env updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'key' , 'value' ],
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
),
],
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variable updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
2026-02-09 13:48:16 +00:00
ref : '#/components/schemas/EnvironmentVariable'
2024-09-05 20:54:20 +00:00
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
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' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function update_env_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-05 20:54:20 +00:00
$validator = customApiValidator ( $request -> all (), [
'key' => 'string|required' ,
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $request -> key ) -> trim () -> replace ( ' ' , '_' ) -> value ;
$env = $service -> environment_variables () -> where ( 'key' , $key ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $env ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Environment variable not found.' ], 404 );
}
$env -> fill ( $request -> all ());
$env -> save ();
return response () -> json ( $this -> removeSensitiveData ( $env )) -> setStatusCode ( 201 );
}
#[OA\Patch(
summary : 'Update Envs (Bulk)' ,
description : 'Update multiple envs by service UUID.' ,
path : '/services/{uuid}/envs/bulk' ,
operationId : 'update-envs-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
description : 'Bulk envs updated.' ,
required : true ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
required : [ 'data' ],
properties : [
'data' => [
'type' => 'array' ,
'items' => new OA\Schema (
type : 'object' ,
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
],
],
),
),
],
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variables updated.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
2026-02-09 13:48:16 +00:00
type : 'array' ,
items : new OA\Items ( ref : '#/components/schemas/EnvironmentVariable' )
2024-09-05 20:54:20 +00:00
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
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' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function create_bulk_envs ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-05 20:54:20 +00:00
$bulk_data = $request -> get ( 'data' );
2024-09-06 08:48:47 +00:00
if ( ! $bulk_data ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Bulk data is required.' ], 400 );
}
$updatedEnvs = collect ();
foreach ( $bulk_data as $item ) {
$validator = customApiValidator ( $item , [
'key' => 'string|required' ,
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $item [ 'key' ]) -> trim () -> replace ( ' ' , '_' ) -> value ;
2024-09-05 20:54:20 +00:00
$env = $service -> environment_variables () -> updateOrCreate (
2024-12-17 09:38:32 +00:00
[ 'key' => $key ],
2024-09-05 20:54:20 +00:00
$item
);
$updatedEnvs -> push ( $this -> removeSensitiveData ( $env ));
}
return response () -> json ( $updatedEnvs ) -> setStatusCode ( 201 );
}
#[OA\Post(
summary : 'Create Env' ,
description : 'Create env by service UUID.' ,
path : '/services/{uuid}/envs' ,
operationId : 'create-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
requestBody : new OA\RequestBody (
required : true ,
description : 'Env created.' ,
content : new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'key' => [ 'type' => 'string' , 'description' => 'The key of the environment variable.' ],
'value' => [ 'type' => 'string' , 'description' => 'The value of the environment variable.' ],
'is_preview' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is used in preview deployments.' ],
'is_literal' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is a literal, nothing espaced.' ],
'is_multiline' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable is multiline.' ],
'is_shown_once' => [ 'type' => 'boolean' , 'description' => 'The flag to indicate if the environment variable\'s value is shown on the UI.' ],
],
),
),
),
responses : [
new OA\Response (
response : 201 ,
description : 'Environment variable created.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'uuid' => [ 'type' => 'string' , 'example' => 'nc0k04gk8g0cgsk440g0koko' ],
]
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
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' ,
),
2025-10-12 12:20:45 +00:00
new OA\Response (
response : 422 ,
ref : '#/components/responses/422' ,
),
2024-09-05 20:54:20 +00:00
]
)]
public function create_env ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2024-09-05 20:54:20 +00:00
$validator = customApiValidator ( $request -> all (), [
'key' => 'string|required' ,
'value' => 'string|nullable' ,
'is_literal' => 'boolean' ,
'is_multiline' => 'boolean' ,
'is_shown_once' => 'boolean' ,
]);
if ( $validator -> fails ()) {
return response () -> json ([
'message' => 'Validation failed.' ,
'errors' => $validator -> errors (),
], 422 );
}
2024-12-17 09:38:32 +00:00
$key = str ( $request -> key ) -> trim () -> replace ( ' ' , '_' ) -> value ;
$existingEnv = $service -> environment_variables () -> where ( 'key' , $key ) -> first ();
2024-09-05 20:54:20 +00:00
if ( $existingEnv ) {
return response () -> json ([
'message' => 'Environment variable already exists. Use PATCH request to update it.' ,
], 409 );
}
$env = $service -> environment_variables () -> create ( $request -> all ());
return response () -> json ( $this -> removeSensitiveData ( $env )) -> setStatusCode ( 201 );
}
#[OA\Delete(
summary : 'Delete Env' ,
description : 'Delete env by UUID.' ,
path : '/services/{uuid}/envs/{env_uuid}' ,
operationId : 'delete-env-by-service-uuid' ,
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
new OA\Parameter (
name : 'env_uuid' ,
in : 'path' ,
description : 'UUID of the environment variable.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Environment variable deleted.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Environment variable deleted.' ],
]
)
),
2024-10-01 07:02:16 +00:00
]
),
2024-09-05 20:54:20 +00:00
new OA\Response (
response : 401 ,
ref : '#/components/responses/401' ,
),
new OA\Response (
response : 400 ,
ref : '#/components/responses/400' ,
),
new OA\Response (
response : 404 ,
ref : '#/components/responses/404' ,
),
]
)]
public function delete_env_by_uuid ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-09-06 08:48:47 +00:00
if ( ! $service ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'manageEnvironment' , $service );
2025-01-07 14:31:43 +00:00
$env = EnvironmentVariable :: where ( 'uuid' , $request -> env_uuid )
2024-12-17 09:38:32 +00:00
-> where ( 'resourceable_type' , Service :: class )
-> where ( 'resourceable_id' , $service -> id )
2024-09-05 20:54:20 +00:00
-> first ();
2024-09-06 08:48:47 +00:00
if ( ! $env ) {
2024-09-05 20:54:20 +00:00
return response () -> json ([ 'message' => 'Environment variable not found.' ], 404 );
}
$env -> forceDelete ();
return response () -> json ([ 'message' => 'Environment variable deleted.' ]);
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'Start' ,
description : 'Start service. `Post` request is also accepted.' ,
path : '/services/{uuid}/start' ,
2024-09-04 08:09:10 +00:00
operationId : 'start-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Start service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service starting request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
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' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_deploy ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'deploy' , $service );
2024-12-13 11:03:05 +00:00
if ( str ( $service -> status ) -> contains ( 'running' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service is already running.' ], 400 );
2024-07-02 14:12:04 +00:00
}
StartService :: dispatch ( $service );
return response () -> json (
[
'message' => 'Service starting request queued.' ,
],
200
);
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'Stop' ,
description : 'Stop service. `Post` request is also accepted.' ,
path : '/services/{uuid}/stop' ,
2024-09-04 08:09:10 +00:00
operationId : 'stop-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
],
responses : [
new OA\Response (
response : 200 ,
description : 'Stop service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service stopping request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
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' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_stop ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'stop' , $service );
2024-12-13 11:03:05 +00:00
if ( str ( $service -> status ) -> contains ( 'stopped' ) || str ( $service -> status ) -> contains ( 'exited' )) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service is already stopped.' ], 400 );
2024-07-02 14:12:04 +00:00
}
StopService :: dispatch ( $service );
return response () -> json (
[
'message' => 'Service stopping request queued.' ,
],
200
);
}
2024-07-09 11:30:13 +00:00
#[OA\Get(
summary : 'Restart' ,
description : 'Restart service. `Post` request is also accepted.' ,
path : '/services/{uuid}/restart' ,
2024-09-04 08:09:10 +00:00
operationId : 'restart-service-by-uuid' ,
2024-07-09 11:30:13 +00:00
security : [
[ 'bearerAuth' => []],
],
tags : [ 'Services' ],
parameters : [
new OA\Parameter (
name : 'uuid' ,
in : 'path' ,
description : 'UUID of the service.' ,
required : true ,
schema : new OA\Schema (
type : 'string' ,
)
),
2025-05-27 13:03:17 +00:00
new OA\Parameter (
name : 'latest' ,
in : 'query' ,
description : 'Pull latest images.' ,
schema : new OA\Schema (
type : 'boolean' ,
default : false ,
)
),
2024-07-09 11:30:13 +00:00
],
responses : [
new OA\Response (
response : 200 ,
description : 'Restart service.' ,
content : [
new OA\MediaType (
mediaType : 'application/json' ,
schema : new OA\Schema (
type : 'object' ,
properties : [
'message' => [ 'type' => 'string' , 'example' => 'Service restaring request queued.' ],
2024-10-01 07:02:16 +00:00
]
)
2024-07-09 11:30:13 +00:00
),
2024-10-01 07:02:16 +00:00
]
),
2024-07-09 11:30:13 +00:00
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' ,
),
]
)]
2024-07-02 14:12:04 +00:00
public function action_restart ( Request $request )
{
$teamId = getTeamIdFromToken ();
if ( is_null ( $teamId )) {
return invalidTokenResponse ();
}
$uuid = $request -> route ( 'uuid' );
if ( ! $uuid ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'UUID is required.' ], 400 );
2024-07-02 14:12:04 +00:00
}
2025-01-07 14:31:43 +00:00
$service = Service :: whereRelation ( 'environment.project.team' , 'id' , $teamId ) -> whereUuid ( $request -> uuid ) -> first ();
2024-07-02 14:12:04 +00:00
if ( ! $service ) {
2024-07-03 11:13:38 +00:00
return response () -> json ([ 'message' => 'Service not found.' ], 404 );
2024-07-02 14:12:04 +00:00
}
2025-08-23 16:51:10 +00:00
$this -> authorize ( 'deploy' , $service );
2025-05-27 13:03:17 +00:00
$pullLatest = $request -> boolean ( 'latest' );
RestartService :: dispatch ( $service , $pullLatest );
2024-07-02 14:12:04 +00:00
return response () -> json (
[
'message' => 'Service restarting request queued.' ,
],
200
);
}
}