From 8ad65a0ef8607f7db7e1b36ec5f55a86e2a430ca Mon Sep 17 00:00:00 2001 From: Bakr Date: Sun, 29 Mar 2026 06:03:47 +0300 Subject: [PATCH 1/4] eat(api): add service-applications API to manage service applications --- .../Service/DeployServiceApplication.php | 58 ++ .../Service/RestartServiceApplication.php | 24 + .../Service/StopServiceApplication.php | 24 + .../UpdateServiceApplicationFromApi.php | 107 +++ .../Api/ServiceApplicationsController.php | 754 ++++++++++++++++++ app/Policies/ServiceApplicationPolicy.php | 11 +- app/Support/ServiceComposeUrl.php | 55 ++ routes/api.php | 9 + tests/Feature/ServiceApplicationsApiTest.php | 258 ++++++ 9 files changed, 1298 insertions(+), 2 deletions(-) create mode 100644 app/Actions/Service/DeployServiceApplication.php create mode 100644 app/Actions/Service/RestartServiceApplication.php create mode 100644 app/Actions/Service/StopServiceApplication.php create mode 100644 app/Actions/Service/UpdateServiceApplicationFromApi.php create mode 100644 app/Http/Controllers/Api/ServiceApplicationsController.php create mode 100644 app/Support/ServiceComposeUrl.php create mode 100644 tests/Feature/ServiceApplicationsApiTest.php diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php new file mode 100644 index 000000000..f79437a26 --- /dev/null +++ b/app/Actions/Service/DeployServiceApplication.php @@ -0,0 +1,58 @@ +service; + $composeServiceName = $serviceApplication->name; + + $service->parse(); + $service->saveComposeConfigs(); + $service->isConfigurationChanged(save: true); + + $workdir = $service->workdir(); + $commands = collect([ + "echo 'Saved configuration files to {$workdir}.'", + "touch {$workdir}/.env", + ]); + + if ($pullLatestImages) { + $commands->push('echo Pulling image for service.'); + $commands->push("docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} pull {$composeServiceName}"); + } + + if ($service->networks()->count() > 0) { + $commands->push('echo Creating Docker network.'); + $commands->push("docker network inspect {$service->uuid} >/dev/null 2>&1 || docker network create --attachable {$service->uuid}"); + } + + $upCommand = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --no-deps"; + if ($forceRebuild) { + $upCommand .= ' --build'; + } + $upCommand .= " {$composeServiceName}"; + $commands->push('echo Starting service container.'); + $commands->push($upCommand); + + $commands->push("docker network connect {$service->uuid} coolify-proxy >/dev/null 2>&1 || true"); + + if (data_get($service, 'connect_to_docker_network')) { + $compose = data_get($service, 'docker_compose', []); + $network = $service->destination->network; + $commands->push("docker network connect --alias {$composeServiceName}-{$service->uuid} {$network} {$composeServiceName}-{$service->uuid} >/dev/null 2>&1 || true"); + } + + return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); + } +} diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php new file mode 100644 index 000000000..f5a7883e5 --- /dev/null +++ b/app/Actions/Service/RestartServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker restart {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php new file mode 100644 index 000000000..d34f1f3c8 --- /dev/null +++ b/app/Actions/Service/StopServiceApplication.php @@ -0,0 +1,24 @@ +service; + $server = $service->destination->server; + $containerName = $serviceApplication->name.'-'.$service->uuid; + + instant_remote_process([ + "docker stop {$containerName}", + ], $server); + } +} diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php new file mode 100644 index 000000000..8a2ce6ecd --- /dev/null +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -0,0 +1,107 @@ +boolean('force_domain_override'); + + if ($request->has('url')) { + $urlRaw = $request->input('url'); + if ($urlRaw !== null && ! is_string($urlRaw)) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['url' => 'The url must be a string.'], + ], 422); + } + + $parsed = ServiceComposeUrl::validateUrlString( + is_string($urlRaw) ? $urlRaw : null, + $forceDomainOverride + ); + + if (count($parsed['errors']) > 0) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $parsed['errors'], + ], 422); + } + + if ($parsed['normalized'] !== null) { + $containerUrls = str($parsed['normalized']) + ->explode(',') + ->map(fn ($url) => str(trim((string) $url))->lower()); + + $result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $serviceApplication->uuid); + if (isset($result['error'])) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [$result['error']], + ], 422); + } + + if ($result['hasConflicts'] && ! $forceDomainOverride) { + return response()->json([ + 'message' => 'Domain conflicts detected. Use force_domain_override=true to proceed.', + 'conflicts' => $result['conflicts'], + 'warning' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.', + ], 409); + } + } + + $serviceApplication->fqdn = $parsed['normalized']; + } + + if ($request->has('human_name')) { + $serviceApplication->human_name = $request->input('human_name'); + } + + if ($request->has('description')) { + $serviceApplication->description = $request->input('description'); + } + + if ($request->has('image')) { + $serviceApplication->image = $request->input('image'); + } + + if ($request->has('exclude_from_status')) { + $serviceApplication->exclude_from_status = $request->boolean('exclude_from_status'); + } + + if ($request->has('is_gzip_enabled')) { + $serviceApplication->is_gzip_enabled = $request->boolean('is_gzip_enabled'); + } + + if ($request->has('is_stripprefix_enabled')) { + $serviceApplication->is_stripprefix_enabled = $request->boolean('is_stripprefix_enabled'); + } + + if ($request->has('is_log_drain_enabled')) { + $enabled = $request->boolean('is_log_drain_enabled'); + $server = $serviceApplication->service->destination->server; + if ($enabled && ! $server->isLogDrainEnabled()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => [ + 'is_log_drain_enabled' => 'Log drain is not enabled on the server for this service.', + ], + ], 422); + } + $serviceApplication->is_log_drain_enabled = $enabled; + } + + $serviceApplication->save(); + $serviceApplication->refresh(); + + updateCompose($serviceApplication); + + return null; + } +} diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php new file mode 100644 index 000000000..da3a8b337 --- /dev/null +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -0,0 +1,754 @@ +makeHidden([ + 'id', + 'resourceable', + 'resourceable_id', + 'resourceable_type', + ]); + + return serializeApiResponse($serviceApplication); + } + + private function resolveService(Request $request, int $teamId): ?Service + { + $uuid = $request->route('uuid'); + if (! $uuid) { + return null; + } + + return Service::whereRelation('environment.project.team', 'id', $teamId) + ->whereUuid($uuid) + ->first(); + } + + private function resolveServiceApplicationForService(Request $request, Service $service): ?ServiceApplication + { + $appUuid = $request->route('app_uuid'); + if (! $appUuid) { + return null; + } + + return $service->applications() + ->where('uuid', $appUuid) + ->with(['service.destination.server']) + ->first(); + } + + private function swarmNotSupportedResponse(): JsonResponse + { + return response()->json([ + 'message' => 'This operation is not supported for Swarm servers yet.', + ], 501); + } + + #[OA\Get( + summary: 'List service applications', + description: 'List compose service applications (containers) for a single service.', + path: '/services/{uuid}/applications', + operationId: 'list-service-applications-by-service-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service applications for this service.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items(type: 'object') + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function index(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $this->authorize('view', $service); + + $items = $service->applications() + ->get() + ->map(fn (ServiceApplication $sa) => $this->removeSensitiveData($sa)); + + return response()->json($items); + } + + #[OA\Get( + summary: 'Get service application', + description: 'Get a single compose service application by service UUID and application UUID.', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'get-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function show(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Patch( + summary: 'Update service application', + description: 'Update fields for a compose service application. Use `url` for comma-separated public URLs (same rules as `urls[].url` on PATCH /services/{uuid}).', + path: '/services/{uuid}/applications/{app_uuid}', + operationId: 'patch-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force_domain_override', + in: 'query', + description: 'When true, allow duplicate URLs in the request and proceed despite domain conflicts (same as service PATCH).', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + requestBody: new OA\RequestBody( + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'url' => new OA\Property( + property: 'url', + type: 'string', + nullable: true, + description: 'Comma-separated list of URLs (e.g. "http://app.example.com:8080,https://app2.example.com"). Stored as fqdn.' + ), + 'human_name' => new OA\Property(property: 'human_name', type: 'string', nullable: true), + 'description' => new OA\Property(property: 'description', type: 'string', nullable: true), + 'image' => new OA\Property(property: 'image', type: 'string', nullable: true), + 'exclude_from_status' => new OA\Property(property: 'exclude_from_status', type: 'boolean', nullable: true), + 'is_log_drain_enabled' => new OA\Property(property: 'is_log_drain_enabled', type: 'boolean', nullable: true), + 'is_gzip_enabled' => new OA\Property(property: 'is_gzip_enabled', type: 'boolean', nullable: true), + 'is_stripprefix_enabled' => new OA\Property(property: 'is_stripprefix_enabled', type: 'boolean', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Updated service application.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema(type: 'object') + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 409, + description: 'Domain conflicts (unless force_domain_override).', + ), + new OA\Response( + response: 422, + ref: '#/components/responses/422', + ), + ] + )] + public function update(Request $request, UpdateServiceApplicationFromApi $updateServiceApplicationFromApi): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('update', $serviceApplication); + + $allowedFields = [ + 'url', + 'human_name', + 'description', + 'image', + 'exclude_from_status', + 'is_log_drain_enabled', + 'is_gzip_enabled', + 'is_stripprefix_enabled', + ]; + + $validationRules = [ + 'url' => 'nullable|string', + 'human_name' => 'nullable|string|max:255', + 'description' => 'nullable|string', + 'image' => 'nullable|string', + 'exclude_from_status' => 'sometimes|boolean', + 'is_log_drain_enabled' => 'sometimes|boolean', + 'is_gzip_enabled' => 'sometimes|boolean', + 'is_stripprefix_enabled' => 'sometimes|boolean', + ]; + + $validator = Validator::make($request->all(), $validationRules); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId); + if ($response instanceof JsonResponse) { + return $response; + } + + $serviceApplication->refresh(); + + return response()->json($this->removeSensitiveData($serviceApplication)); + } + + #[OA\Get( + summary: 'Get service application logs', + description: 'Get Docker logs for a single compose service container.', + path: '/services/{uuid}/applications/{app_uuid}/logs', + operationId: 'get-service-application-logs-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'lines', + in: 'query', + description: 'Number of lines to show from the end of the logs.', + required: false, + schema: new OA\Schema(type: 'integer', format: 'int32', default: 100) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Logs.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'logs' => new OA\Property(property: 'logs', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function logs_by_uuid(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('view', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + + $status = getContainerStatus($server, $containerName); + if ($status !== 'running') { + return response()->json([ + 'message' => 'Service application container is not running.', + ], 400); + } + + $lines = (int) ($request->query('lines', 100) ?: 100); + $logs = getContainerLogs($server, $containerName, $lines); + + return response()->json([ + 'logs' => $logs, + ]); + } + + #[OA\Get( + summary: 'Start or redeploy service application container', + description: 'Runs docker compose up for a single compose service (no-deps), optionally pulling the image and rebuilding.', + path: '/services/{uuid}/applications/{app_uuid}/start', + operationId: 'start-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'force', + in: 'query', + description: 'When true, passes --build to docker compose up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + new OA\Parameter( + name: 'latest', + in: 'query', + description: 'When true, pulls the image for this compose service before up.', + required: false, + schema: new OA\Schema(type: 'boolean', default: false) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Deploy request queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_start(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + $pullLatest = $request->boolean('latest', false); + $forceRebuild = $request->boolean('force', false); + + DeployServiceApplication::dispatch($serviceApplication, $pullLatest, $forceRebuild); + + return response()->json([ + 'message' => 'Service application deploy request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Restart service application container', + description: 'Restarts a single compose service container (docker restart).', + path: '/services/{uuid}/applications/{app_uuid}/restart', + operationId: 'restart-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Restart queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_restart(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + RestartServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application restart request queued.', + ], 200); + } + + #[OA\Get( + summary: 'Stop service application container', + description: 'Stops a single compose service container (docker stop).', + path: '/services/{uuid}/applications/{app_uuid}/stop', + operationId: 'stop-service-application-by-service-and-app-uuid', + security: [ + ['bearerAuth' => []], + ], + tags: ['Service applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'Service UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + new OA\Parameter( + name: 'app_uuid', + in: 'path', + description: 'Service application UUID.', + required: true, + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Stop queued.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + new OA\Response( + response: 501, + description: 'Swarm not supported.', + ), + ] + )] + public function action_stop(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $service = $this->resolveService($request, $teamId); + if (! $service) { + return response()->json(['message' => 'Service not found.'], 404); + } + + $serviceApplication = $this->resolveServiceApplicationForService($request, $service); + if (! $serviceApplication) { + return response()->json(['message' => 'Service application not found.'], 404); + } + + $this->authorize('deploy', $serviceApplication); + + $server = $serviceApplication->service->destination->server; + if ($server->isSwarm()) { + return $this->swarmNotSupportedResponse(); + } + + if (! $server->isFunctional()) { + return response()->json([ + 'message' => 'Server is not functional.', + ], 400); + } + + StopServiceApplication::dispatch($serviceApplication); + + return response()->json([ + 'message' => 'Service application stop request queued.', + ], 200); + } +} diff --git a/app/Policies/ServiceApplicationPolicy.php b/app/Policies/ServiceApplicationPolicy.php index af380a90f..619b885ff 100644 --- a/app/Policies/ServiceApplicationPolicy.php +++ b/app/Policies/ServiceApplicationPolicy.php @@ -30,8 +30,15 @@ public function create(User $user): bool */ public function update(User $user, ServiceApplication $serviceApplication): bool { - // return Gate::allows('update', $serviceApplication->service); - return true; + return Gate::allows('update', $serviceApplication->service); + } + + /** + * Determine whether the user can deploy or run lifecycle actions on the parent service stack. + */ + public function deploy(User $user, ServiceApplication $serviceApplication): bool + { + return Gate::allows('deploy', $serviceApplication->service); } /** diff --git a/app/Support/ServiceComposeUrl.php b/app/Support/ServiceComposeUrl.php new file mode 100644 index 000000000..cdeb75e58 --- /dev/null +++ b/app/Support/ServiceComposeUrl.php @@ -0,0 +1,55 @@ +, normalized: ?string} + */ + public static function validateUrlString(?string $urlValue, bool $forceDomainOverride = false): array + { + $errors = []; + + if ($urlValue === null || $urlValue === '') { + return ['errors' => [], 'normalized' => null]; + } + + $urls = str($urlValue) + ->replaceStart(',', '') + ->replaceEnd(',', '') + ->trim() + ->explode(',') + ->map(fn ($url) => trim((string) $url)) + ->filter(); + + foreach ($urls as $url) { + if (! filter_var($url, FILTER_VALIDATE_URL)) { + $errors[] = "Invalid URL: {$url}"; + } + $scheme = parse_url($url, PHP_URL_SCHEME) ?? ''; + if (! in_array(strtolower($scheme), ['http', 'https'], true)) { + $errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported."; + } + } + + $duplicates = $urls->duplicates()->unique()->values(); + if ($duplicates->isNotEmpty() && ! $forceDomainOverride) { + $errors[] = 'The current request contains duplicate URLs: '.implode(', ', $duplicates->toArray()).'. Use force_domain_override=true to proceed.'; + } + + if (count($errors) > 0) { + return ['errors' => $errors, 'normalized' => null]; + } + + $normalized = $urls + ->map(fn ($u) => str($u)->lower()->value()) + ->unique() + ->filter(fn ($u) => filled($u)) + ->implode(','); + + return ['errors' => [], 'normalized' => $normalized !== '' ? $normalized : null]; + } +} diff --git a/routes/api.php b/routes/api.php index 0d3edcced..57ec3934a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,6 +12,7 @@ use App\Http\Controllers\Api\ScheduledTasksController; use App\Http\Controllers\Api\SecurityController; use App\Http\Controllers\Api\ServersController; +use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Api\ServicesController; use App\Http\Controllers\Api\TeamController; use App\Http\Middleware\ApiAllowed; @@ -193,6 +194,14 @@ Route::match(['get', 'post'], '/services/{uuid}/restart', [ServicesController::class, 'action_restart'])->middleware(['api.ability:deploy']); Route::match(['get', 'post'], '/services/{uuid}/stop', [ServicesController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/services/{uuid}/applications', [ServiceApplicationsController::class, 'index'])->middleware(['api.ability:read']); + Route::get('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/services/{uuid}/applications/{app_uuid}', [ServiceApplicationsController::class, 'update'])->middleware(['api.ability:write']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/logs', [ServiceApplicationsController::class, 'logs_by_uuid'])->middleware(['api.ability:read']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/start', [ServiceApplicationsController::class, 'action_start'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/restart', [ServiceApplicationsController::class, 'action_restart'])->middleware(['api.ability:deploy']); + Route::match(['get', 'post'], '/services/{uuid}/applications/{app_uuid}/stop', [ServiceApplicationsController::class, 'action_stop'])->middleware(['api.ability:deploy']); + Route::get('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'scheduled_tasks_by_application_uuid'])->middleware(['api.ability:read']); Route::post('/applications/{uuid}/scheduled-tasks', [ScheduledTasksController::class, 'create_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); Route::patch('/applications/{uuid}/scheduled-tasks/{task_uuid}', [ScheduledTasksController::class, 'update_scheduled_task_by_application_uuid'])->middleware(['api.ability:write']); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php new file mode 100644 index 000000000..d4f12350e --- /dev/null +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -0,0 +1,258 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->server->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); +}); + +function createServiceWithApplicationForApiTest(object $ctx): object +{ + $service = Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); + + $sa = ServiceApplication::create([ + 'uuid' => (string) Str::uuid(), + 'name' => 'web', + 'service_id' => $service->id, + 'image' => 'nginx:alpine', + ]); + + return (object) ['service' => $service, 'serviceApplication' => $sa]; +} + +function createServiceWithoutApplicationsForApiTest(object $ctx): Service +{ + return Service::factory()->create([ + 'environment_id' => $ctx->environment->id, + 'server_id' => $ctx->server->id, + 'destination_id' => $ctx->destination->id, + 'destination_type' => $ctx->destination->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n", + ]); +} + +describe('GET /api/v1/services/{uuid}/applications', function () { + test('returns empty array when service has no applications', function () { + $service = createServiceWithoutApplicationsForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJson([]); + }); + + test('lists service applications for the service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid]); + }); + + test('returns 404 when service does not exist', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000001/applications'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 404 for unknown service', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/services/00000000-0000-0000-0000-000000000002/applications/non-existent-uuid-12345'); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); + + test('returns 404 when application uuid is not under service', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/00000000-0000-0000-0000-000000000003"); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service application not found.']); + }); + + test('returns service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}"); + + $response->assertStatus(200); + $response->assertJsonFragment(['uuid' => $ctx->serviceApplication->uuid, 'name' => 'web']); + }); +}); + +describe('PATCH /api/v1/services/{uuid}/applications/{app_uuid}', function () { + test('returns 400 without valid token', function () { + $response = $this->patchJson('/api/v1/services/some-uuid/applications/some-app', [ + 'human_name' => 'x', + ], ['Accept' => 'application/json', 'Content-Type' => 'application/json']); + + $response->assertStatus(400); + }); + + test('updates human_name', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'human_name' => 'Web UI', + ]); + + $response->assertStatus(200); + $response->assertJsonFragment(['human_name' => 'Web UI']); + $ctx->serviceApplication->refresh(); + expect($ctx->serviceApplication->human_name)->toBe('Web UI'); + }); + + test('returns 422 for invalid url scheme', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'url' => 'ftp://example.com', + ]); + + $response->assertStatus(422); + }); + + test('returns 422 when enabling log drain but server has no log drain', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'is_log_drain_enabled' => true, + ]); + + $response->assertStatus(422); + expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain'); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () { + test('returns 400 without valid token', function () { + $response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart'); + + $response->assertStatus(400); + }); + + test('queues restart for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/restart"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application restart request queued.']); + RestartServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () { + test('queues deploy for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start?latest=1&force=1"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application deploy request queued.']); + DeployServiceApplication::assertPushed(); + }); +}); + +describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/stop', function () { + test('queues stop for a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/stop"); + + $response->assertStatus(200); + $response->assertJsonFragment(['message' => 'Service application stop request queued.']); + StopServiceApplication::assertPushed(); + }); +}); + +describe('GET /api/v1/services/{uuid}/applications/{app_uuid}/logs', function () { + test('returns 400 when server is not functional', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $this->server->settings->update([ + 'is_reachable' => false, + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/logs"); + + $response->assertStatus(400); + $response->assertJsonFragment(['message' => 'Server is not functional.']); + }); +}); From a77e91eca4518c3317efd78a055eb0b59d5e14c3 Mon Sep 17 00:00:00 2001 From: Bakr Date: Mon, 30 Mar 2026 22:14:34 +0300 Subject: [PATCH 2/4] fix(api): return array from removeSensitiveData for service applications list --- .../Controllers/Api/ServiceApplicationsController.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index da3a8b337..75a48d112 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -11,6 +11,7 @@ use App\Models\ServiceApplication; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Validator; use OpenApi\Attributes as OA; @@ -25,7 +26,13 @@ private function removeSensitiveData(ServiceApplication $serviceApplication): ar 'resourceable_type', ]); - return serializeApiResponse($serviceApplication); + $serialized = serializeApiResponse($serviceApplication); + + if ($serialized instanceof Collection) { + return $serialized->all(); + } + + return (array) $serialized; } private function resolveService(Request $request, int $teamId): ?Service From a165e03d06e00100970fbebf062f8e4857d41dc8 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:10:24 +0200 Subject: [PATCH 3/4] fix(service-apps): harden updates and docker commands Escape service application lifecycle command arguments for deploy, restart, stop, and log status checks. Validate API update payloads from JSON/form data before applying allowed fields, preserving explicit boolean and null values. Add coverage for service application API authorization, team isolation, validation, and command escaping. --- .../Service/DeployServiceApplication.php | 27 ++- .../Service/RestartServiceApplication.php | 2 +- .../Service/StopServiceApplication.php | 2 +- .../UpdateServiceApplicationFromApi.php | 36 ++-- .../Api/ServiceApplicationsController.php | 14 +- templates/service-templates-latest.json | 188 +++++++++--------- templates/service-templates.json | 188 +++++++++--------- .../Security/CommandInjectionSecurityTest.php | 29 +++ tests/Feature/ServiceApplicationsApiTest.php | 103 +++++++++- 9 files changed, 356 insertions(+), 233 deletions(-) diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index f79437a26..363166bcc 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -22,35 +22,42 @@ public function handle(ServiceApplication $serviceApplication, bool $pullLatestI $service->isConfigurationChanged(save: true); $workdir = $service->workdir(); + $composeFile = "{$workdir}/docker-compose.yml"; + $safeWorkdir = escapeshellarg($workdir); + $safeComposeFile = escapeshellarg($composeFile); + $safeProjectName = escapeshellarg($service->uuid); + $safeComposeServiceName = escapeshellarg($composeServiceName); + $commands = collect([ - "echo 'Saved configuration files to {$workdir}.'", - "touch {$workdir}/.env", + 'echo '.escapeshellarg("Saved configuration files to {$workdir}."), + 'touch '.escapeshellarg("{$workdir}/.env"), ]); if ($pullLatestImages) { $commands->push('echo Pulling image for service.'); - $commands->push("docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} pull {$composeServiceName}"); + $commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName}"); } if ($service->networks()->count() > 0) { $commands->push('echo Creating Docker network.'); - $commands->push("docker network inspect {$service->uuid} >/dev/null 2>&1 || docker network create --attachable {$service->uuid}"); + $commands->push("docker network inspect {$safeProjectName} >/dev/null 2>&1 || docker network create --attachable {$safeProjectName}"); } - $upCommand = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --no-deps"; + $upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps"; if ($forceRebuild) { $upCommand .= ' --build'; } - $upCommand .= " {$composeServiceName}"; + $upCommand .= " {$safeComposeServiceName}"; $commands->push('echo Starting service container.'); $commands->push($upCommand); - $commands->push("docker network connect {$service->uuid} coolify-proxy >/dev/null 2>&1 || true"); + $commands->push("docker network connect {$safeProjectName} coolify-proxy >/dev/null 2>&1 || true"); if (data_get($service, 'connect_to_docker_network')) { - $compose = data_get($service, 'docker_compose', []); - $network = $service->destination->network; - $commands->push("docker network connect --alias {$composeServiceName}-{$service->uuid} {$network} {$composeServiceName}-{$service->uuid} >/dev/null 2>&1 || true"); + $network = escapeshellarg($service->destination->network); + $containerName = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $networkAlias = escapeshellarg("{$composeServiceName}-{$service->uuid}"); + $commands->push("docker network connect --alias {$networkAlias} {$network} {$containerName} >/dev/null 2>&1 || true"); } return remote_process($commands->toArray(), $service->server, type_uuid: $service->uuid, callEventOnFinish: 'ServiceStatusChanged'); diff --git a/app/Actions/Service/RestartServiceApplication.php b/app/Actions/Service/RestartServiceApplication.php index f5a7883e5..c83cbe660 100644 --- a/app/Actions/Service/RestartServiceApplication.php +++ b/app/Actions/Service/RestartServiceApplication.php @@ -15,7 +15,7 @@ public function handle(ServiceApplication $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; - $containerName = $serviceApplication->name.'-'.$service->uuid; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); instant_remote_process([ "docker restart {$containerName}", diff --git a/app/Actions/Service/StopServiceApplication.php b/app/Actions/Service/StopServiceApplication.php index d34f1f3c8..cc1afbb96 100644 --- a/app/Actions/Service/StopServiceApplication.php +++ b/app/Actions/Service/StopServiceApplication.php @@ -15,7 +15,7 @@ public function handle(ServiceApplication $serviceApplication): void { $service = $serviceApplication->service; $server = $service->destination->server; - $containerName = $serviceApplication->name.'-'.$service->uuid; + $containerName = escapeshellarg($serviceApplication->name.'-'.$service->uuid); instant_remote_process([ "docker stop {$containerName}", diff --git a/app/Actions/Service/UpdateServiceApplicationFromApi.php b/app/Actions/Service/UpdateServiceApplicationFromApi.php index 8a2ce6ecd..7bc04dfdb 100644 --- a/app/Actions/Service/UpdateServiceApplicationFromApi.php +++ b/app/Actions/Service/UpdateServiceApplicationFromApi.php @@ -9,12 +9,12 @@ class UpdateServiceApplicationFromApi { - public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId): ?JsonResponse + public function execute(ServiceApplication $serviceApplication, Request $request, string $teamId, array $payload): ?JsonResponse { $forceDomainOverride = $request->boolean('force_domain_override'); - if ($request->has('url')) { - $urlRaw = $request->input('url'); + if (array_key_exists('url', $payload)) { + $urlRaw = $payload['url']; if ($urlRaw !== null && ! is_string($urlRaw)) { return response()->json([ 'message' => 'Validation failed.', @@ -59,38 +59,38 @@ public function execute(ServiceApplication $serviceApplication, Request $request $serviceApplication->fqdn = $parsed['normalized']; } - if ($request->has('human_name')) { - $serviceApplication->human_name = $request->input('human_name'); + if (array_key_exists('human_name', $payload)) { + $serviceApplication->human_name = $payload['human_name']; } - if ($request->has('description')) { - $serviceApplication->description = $request->input('description'); + if (array_key_exists('description', $payload)) { + $serviceApplication->description = $payload['description']; } - if ($request->has('image')) { - $serviceApplication->image = $request->input('image'); + if (array_key_exists('image', $payload)) { + $serviceApplication->image = $payload['image']; } - if ($request->has('exclude_from_status')) { - $serviceApplication->exclude_from_status = $request->boolean('exclude_from_status'); + if (array_key_exists('exclude_from_status', $payload)) { + $serviceApplication->exclude_from_status = filter_var($payload['exclude_from_status'], FILTER_VALIDATE_BOOLEAN); } - if ($request->has('is_gzip_enabled')) { - $serviceApplication->is_gzip_enabled = $request->boolean('is_gzip_enabled'); + if (array_key_exists('is_gzip_enabled', $payload)) { + $serviceApplication->is_gzip_enabled = filter_var($payload['is_gzip_enabled'], FILTER_VALIDATE_BOOLEAN); } - if ($request->has('is_stripprefix_enabled')) { - $serviceApplication->is_stripprefix_enabled = $request->boolean('is_stripprefix_enabled'); + if (array_key_exists('is_stripprefix_enabled', $payload)) { + $serviceApplication->is_stripprefix_enabled = filter_var($payload['is_stripprefix_enabled'], FILTER_VALIDATE_BOOLEAN); } - if ($request->has('is_log_drain_enabled')) { - $enabled = $request->boolean('is_log_drain_enabled'); + if (array_key_exists('is_log_drain_enabled', $payload)) { + $enabled = filter_var($payload['is_log_drain_enabled'], FILTER_VALIDATE_BOOLEAN); $server = $serviceApplication->service->destination->server; if ($enabled && ! $server->isLogDrainEnabled()) { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ - 'is_log_drain_enabled' => 'Log drain is not enabled on the server for this service.', + 'is_log_drain_enabled' => ['Log drain is not enabled on the server for this service.'], ], ], 422); } diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index 75a48d112..de07f2e33 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -306,6 +306,11 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update $this->authorize('update', $serviceApplication); + $payload = $request->json()->all(); + if (empty($payload)) { + $payload = $request->request->all(); + } + $allowedFields = [ 'url', 'human_name', @@ -328,9 +333,9 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update 'is_stripprefix_enabled' => 'sometimes|boolean', ]; - $validator = Validator::make($request->all(), $validationRules); + $validator = Validator::make($payload, $validationRules); - $extraFields = array_diff(array_keys($request->all()), $allowedFields); + $extraFields = array_diff(array_keys($payload), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { $errors = $validator->errors(); foreach ($extraFields as $field) { @@ -343,7 +348,7 @@ public function update(Request $request, UpdateServiceApplicationFromApi $update ], 422); } - $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId); + $response = $updateServiceApplicationFromApi->execute($serviceApplication, $request, $teamId, $payload); if ($response instanceof JsonResponse) { return $response; } @@ -450,8 +455,9 @@ public function logs_by_uuid(Request $request): JsonResponse } $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; + $safeContainerName = escapeshellarg($containerName); - $status = getContainerStatus($server, $containerName); + $status = getContainerStatus($server, $safeContainerName); if ($status !== 'running') { return response()->json([ 'message' => 'Service application container is not running.', diff --git a/templates/service-templates-latest.json b/templates/service-templates-latest.json index 19c213764..0a2dfda9d 100644 --- a/templates/service-templates-latest.json +++ b/templates/service-templates-latest.json @@ -31,7 +31,7 @@ "category": "finance", "logo": "svgs/actualbudget.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5006" }, "affine": { @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:03:32+01:00", + "template_last_updated_at": "2026-04-05T13:36:24+02:00", "port": "8200" }, "anythingllm": { @@ -175,7 +175,7 @@ "category": "ai", "logo": "svgs/argilla.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "6900" }, "audiobookshelf": { @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -231,7 +231,7 @@ "category": "database", "logo": "svgs/autobase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-19T18:13:51+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "babybuddy": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -377,7 +377,7 @@ "category": "finance", "logo": "svgs/budge.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", @@ -438,7 +438,7 @@ "category": "media", "logo": "svgs/calibre-web-automated-with-downloader.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-08T21:32:32+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8083" }, "calibre-web": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -493,7 +493,7 @@ "category": "media", "logo": "svgs/cap.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-14T23:42:45+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5679" }, "castopod": { @@ -552,7 +552,7 @@ "category": "helpdesk", "logo": "svgs/chaskiq.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "chatwoot": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -605,7 +605,7 @@ "category": "storage", "logo": "svgs/chibisafe.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T22:51:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "chroma": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-10T14:16:14+01:00", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -903,7 +903,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T19:25:51+05:30", "port": "8055" }, "directus": { @@ -919,7 +919,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T19:26:06+05:30", "port": "8055" }, "diun": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1044,7 +1044,7 @@ "category": "productivity", "logo": "svgs/dolibarr.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-18T10:27:24+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "dozzle-with-auth": { @@ -1093,7 +1093,7 @@ "category": "devtools", "logo": "svgs/drizzle.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4983" }, "drupal-with-postgresql": { @@ -1161,7 +1161,7 @@ "category": "monitoring", "logo": "svgs/elasticsearch.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:34:40+05:30", + "template_last_updated_at": "2026-04-09T00:43:59-05:00", "port": "5601" }, "elasticsearch": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-16T18:23:47+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "evolution-api": { @@ -1436,7 +1436,7 @@ "category": "finance", "logo": "svgs/firefly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "firefox": { @@ -1625,7 +1625,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:12:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "foundryvtt": { @@ -1642,7 +1642,7 @@ "category": "games", "logo": "svgs/foundryvtt.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "30000" }, "freescout": { @@ -1658,7 +1658,7 @@ "category": "helpdesk", "logo": "svgs/freescout.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-mariadb": { @@ -1672,7 +1672,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-mysql": { @@ -1686,7 +1686,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-postgresql": { @@ -1700,7 +1700,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-04T17:41:25+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss": { @@ -1714,7 +1714,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "garage": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -1910,7 +1910,7 @@ "category": "productivity", "logo": "svgs/glance.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-18T19:18:07+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "glances": { @@ -2000,7 +2000,7 @@ "category": "messaging", "logo": "svgs/gotify.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-08T11:43:21+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "gowa": { @@ -2017,7 +2017,7 @@ "category": "messaging", "logo": "svgs/gowa.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "grafana-with-postgresql": { @@ -2080,7 +2080,7 @@ "category": null, "logo": "svgs/grimmory.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-03T18:59:26+05:30", "port": "80" }, "grist": { @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2129,7 +2129,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:56+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "healthchecks": { @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2253,7 +2253,7 @@ "category": "productivity", "logo": "svgs/homebox.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "7745" }, "homepage": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2378,7 +2378,7 @@ "category": "finance", "logo": "svgs/invoiceninja.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9000" }, "it-tools": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2713,7 +2713,7 @@ "category": "ai", "logo": "svgs/librechat.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T18:29:44+05:30", "port": "3080" }, "libreoffice": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3005,7 +3005,7 @@ "category": "messaging", "logo": "svgs/mattermost.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-04T16:02:48+03:30", + "template_last_updated_at": "2026-04-06T13:57:40-05:00", "port": "8065" }, "mealie": { @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3149,7 +3149,7 @@ "category": "games", "logo": "svgs/minecraft.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-01T15:06:17-05:00", "port": "25565" }, "miniflux": { @@ -3166,7 +3166,7 @@ "category": "RSS", "logo": "svgs/miniflux.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "mixpost": { @@ -3242,7 +3242,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-08T01:08:18-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "n8n-with-postgresql": { @@ -3261,7 +3261,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-27T22:41:52-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "n8n": { @@ -3280,7 +3280,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-27T22:41:52-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "navidrome": { @@ -3328,7 +3328,7 @@ "category": "vpn", "logo": "svgs/netbird.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-16T10:33:10+05:30" + "template_last_updated_at": "2026-04-09T00:00:38+05:30" }, "newapi": { "documentation": "https://docs.newapi.pro/en/getting-started/?utm_source=coolify.io", @@ -3360,7 +3360,7 @@ "category": "proxy", "logo": "svgs/pangolin-logo.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-12T11:59:36-03:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "next-image-transformation": { "documentation": "https://github.com/coollabsio/next-image-transformation?utm_source=coolify.io", @@ -3392,7 +3392,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud-with-mysql": { @@ -3409,7 +3409,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud-with-postgres": { @@ -3426,7 +3426,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud": { @@ -3443,7 +3443,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nexus-arm": { @@ -3554,7 +3554,7 @@ "category": "productivity", "logo": "svgs/nocodb.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "nodebb": { @@ -3621,7 +3621,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8069" }, "ollama-with-open-webui": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T23:41:36+01:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3789,7 +3789,7 @@ "category": "analytics", "logo": "svgs/openpanel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T20:48:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "opnform": { @@ -3810,7 +3810,7 @@ "category": "productivity", "logo": "svg/opnform.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-24T10:13:08+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "orangehrm": { @@ -3828,7 +3828,7 @@ "category": "productivity", "logo": "svgs/orangehrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "organizr": { @@ -3857,7 +3857,7 @@ "category": "helpdesk", "logo": "svgs/osticket.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "overseerr": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -3918,7 +3918,7 @@ "category": "games", "logo": "svgs/default.webp", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-15T21:55:50+01:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "paperless": { "documentation": "https://docs.paperless-ngx.com/configuration/?utm_source=coolify.io", @@ -3959,7 +3959,7 @@ "category": "finance", "logo": "svgs/paymenter.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-23T12:08:21+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "penpot-with-s3": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4108,7 +4108,7 @@ "category": "email", "logo": "svgs/plunk.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "pocket-id-with-postgresql": { @@ -4265,7 +4265,7 @@ "category": "proxy", "logo": "svgs/hoppscotch.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-13T19:24:05+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9159" }, "qbittorrent": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4428,7 +4428,7 @@ "category": "productivity", "logo": "svgs/redmine.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:56+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "rivet-engine": { @@ -4446,7 +4446,7 @@ "category": "development", "logo": "svgs/rivet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-22T16:07:42+02:00", + "template_last_updated_at": "2026-04-01T19:58:30+02:00", "port": "6420" }, "rocketchat": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -4621,7 +4621,7 @@ "category": "storage", "logo": "svgs/sftpgo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T23:44:06+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "shlink": { @@ -4660,7 +4660,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-11T17:51:10+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "silverbullet": { @@ -4675,7 +4675,7 @@ "category": "productivity", "logo": "svgs/silverbullet.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-05T15:42:12+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "siyuan": { @@ -4759,7 +4759,7 @@ "category": "devtools", "logo": "svgs/soketi-app-manager.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:17+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "soketi": { @@ -4898,7 +4898,7 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-01-20T19:10:07+01:00", + "template_last_updated_at": "2026-04-05T20:21:11+02:00", "port": "8000" }, "superset-with-postgresql": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-08T18:51:23+01:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { @@ -5224,7 +5224,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-04T19:45:35+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4242" }, "unleash-without-database": { @@ -5241,7 +5241,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4242" }, "unstructured": { @@ -5339,7 +5339,7 @@ "category": "email", "logo": "svgs/usesend.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-07T17:32:23+11:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "vaultwarden": { @@ -5774,7 +5774,7 @@ "category": "storage", "logo": "svgs/cells.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-13T18:47:54+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" } } diff --git a/templates/service-templates.json b/templates/service-templates.json index e5c56c5dc..4d97d8d5e 100644 --- a/templates/service-templates.json +++ b/templates/service-templates.json @@ -31,7 +31,7 @@ "category": "finance", "logo": "svgs/actualbudget.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5006" }, "affine": { @@ -64,7 +64,7 @@ "category": "productivity", "logo": "svgs/alexandrie.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:03:32+01:00", + "template_last_updated_at": "2026-04-05T13:36:24+02:00", "port": "8200" }, "anythingllm": { @@ -175,7 +175,7 @@ "category": "ai", "logo": "svgs/argilla.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "6900" }, "audiobookshelf": { @@ -192,7 +192,7 @@ "category": "media", "logo": "svgs/audiobookshelf.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:09:36+02:00", "port": "80" }, "authentik": { @@ -231,7 +231,7 @@ "category": "database", "logo": "svgs/autobase.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-19T18:13:51+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "babybuddy": { @@ -280,7 +280,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30" + "template_last_updated_at": "2026-04-24T02:22:08+05:30" }, "beszel": { "documentation": "https://github.com/henrygd/beszel?tab=readme-ov-file#getting-started?utm_source=coolify.io", @@ -296,7 +296,7 @@ "category": "monitoring", "logo": "svgs/beszel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-21T23:17:23+05:30", + "template_last_updated_at": "2026-04-24T02:21:33+05:30", "port": "8090" }, "bitcoin-core": { @@ -326,7 +326,7 @@ "category": "backend", "logo": "svgs/bluesky.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-03T22:12:21+01:00", + "template_last_updated_at": "2026-05-09T19:19:48+05:30", "port": "3000" }, "bookstack": { @@ -377,7 +377,7 @@ "category": "finance", "logo": "svgs/budge.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "budibase": { "documentation": "https://docs.budibase.com/docs/docker-compose?utm_source=coolify.io", @@ -438,7 +438,7 @@ "category": "media", "logo": "svgs/calibre-web-automated-with-downloader.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-08T21:32:32+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8083" }, "calibre-web": { @@ -475,7 +475,7 @@ "category": "security", "logo": "svgs/cap-captcha.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-23T01:07:14+05:30", "port": "3000" }, "cap": { @@ -493,7 +493,7 @@ "category": "media", "logo": "svgs/cap.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-14T23:42:45+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "5679" }, "castopod": { @@ -552,7 +552,7 @@ "category": "helpdesk", "logo": "svgs/chaskiq.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "chatwoot": { @@ -573,7 +573,7 @@ "category": "helpdesk", "logo": "svgs/chatwoot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-27T09:31:29-03:00", "port": "3000" }, "checkmate": { @@ -605,7 +605,7 @@ "category": "storage", "logo": "svgs/chibisafe.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T22:51:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "chroma": { @@ -704,7 +704,7 @@ "category": "automation", "logo": "svgs/cloudflare-ddns.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-05-18T15:42:31+10:00" }, "cloudflared": { "documentation": "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/?utm_source=coolify.io", @@ -803,7 +803,7 @@ "category": "backend", "logo": "svgs/convex.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-10T14:16:14+01:00", + "template_last_updated_at": "2026-06-12T10:45:52+02:00", "port": "6791" }, "cryptgeon": { @@ -903,7 +903,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T19:25:51+05:30", "port": "8055" }, "directus": { @@ -919,7 +919,7 @@ "category": "cms", "logo": "svgs/directus.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T19:26:06+05:30", "port": "8055" }, "diun": { @@ -968,7 +968,7 @@ "category": "productivity", "logo": "svgs/docmost.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-27T04:00:56+02:00", + "template_last_updated_at": "2026-05-15T13:36:02+02:00", "port": "3000" }, "documenso": { @@ -1044,7 +1044,7 @@ "category": "productivity", "logo": "svgs/dolibarr.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-18T10:27:24+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "dozzle-with-auth": { @@ -1093,7 +1093,7 @@ "category": "devtools", "logo": "svgs/drizzle.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4983" }, "drupal-with-postgresql": { @@ -1161,7 +1161,7 @@ "category": "monitoring", "logo": "svgs/elasticsearch.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:34:40+05:30", + "template_last_updated_at": "2026-04-09T00:43:59-05:00", "port": "5601" }, "elasticsearch": { @@ -1248,7 +1248,7 @@ "category": "Networking", "logo": "svgs/emqx-enterprise.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-27T09:25:48+03:00", "port": "18083" }, "ente-photos-with-s3": { @@ -1322,7 +1322,7 @@ "category": "productivity", "logo": "svgs/espocrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-16T18:23:47+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "evolution-api": { @@ -1436,7 +1436,7 @@ "category": "finance", "logo": "svgs/firefly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "firefox": { @@ -1625,7 +1625,7 @@ "category": "productivity", "logo": "svgs/formbricks.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T21:12:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "foundryvtt": { @@ -1642,7 +1642,7 @@ "category": "games", "logo": "svgs/foundryvtt.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "30000" }, "freescout": { @@ -1658,7 +1658,7 @@ "category": "helpdesk", "logo": "svgs/freescout.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-mariadb": { @@ -1672,7 +1672,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-mysql": { @@ -1686,7 +1686,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss-with-postgresql": { @@ -1700,7 +1700,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-04T17:41:25+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "freshrss": { @@ -1714,7 +1714,7 @@ "category": "RSS", "logo": "svgs/freshrss.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "garage": { @@ -1732,7 +1732,7 @@ "category": "storage", "logo": "svgs/garage.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-10T12:57:34+01:00", + "template_last_updated_at": "2026-05-31T23:57:46+02:00", "port": "3900" }, "getoutline": { @@ -1746,7 +1746,7 @@ "category": "productivity", "logo": "svgs/getoutline.jpeg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-16T14:12:15+07:00", + "template_last_updated_at": "2026-05-09T19:19:29+05:30", "port": "3000" }, "ghost": { @@ -1779,7 +1779,7 @@ "category": "devtools", "logo": "svgs/gitea.svg", "minversion": "0.0.0", - "template_last_updated_at": null + "template_last_updated_at": "2026-06-06T00:11:24+02:00" }, "gitea-with-mariadb": { "documentation": "https://docs.gitea.com?utm_source=coolify.io", @@ -1910,7 +1910,7 @@ "category": "productivity", "logo": "svgs/glance.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-18T19:18:07+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "glances": { @@ -2000,7 +2000,7 @@ "category": "messaging", "logo": "svgs/gotify.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-08T11:43:21+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "gowa": { @@ -2017,7 +2017,7 @@ "category": "messaging", "logo": "svgs/gowa.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "grafana-with-postgresql": { @@ -2080,7 +2080,7 @@ "category": null, "logo": "svgs/grimmory.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-04-03T18:59:26+05:30", "port": "80" }, "grist": { @@ -2114,7 +2114,7 @@ "category": "productivity", "logo": "svgs/grocy.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-05-12T09:10:44+02:00" }, "hatchet": { "documentation": "https://docs.hatchet.run/self-hosting/docker-compose?utm_source=coolify.io", @@ -2129,7 +2129,7 @@ "category": "automation", "logo": "svgs/hatchet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:56+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "healthchecks": { @@ -2148,7 +2148,7 @@ "category": "monitoring", "logo": "svgs/healthchecks.webp", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-25T13:06:59-04:00", "port": "80000" }, "heimdall": { @@ -2183,7 +2183,7 @@ "category": "ai", "logo": "svgs/hermes-agent.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-29T12:14:17+05:30", "port": "8787" }, "heyform": { @@ -2218,7 +2218,7 @@ "category": "productivity", "logo": "svgs/homarr.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-07T12:19:45+02:00", + "template_last_updated_at": "2026-05-09T19:30:07+05:30", "port": "7575" }, "home-assistant": { @@ -2253,7 +2253,7 @@ "category": "productivity", "logo": "svgs/homebox.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "7745" }, "homepage": { @@ -2361,7 +2361,7 @@ "category": "automation", "logo": "svgs/inngest.png", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-07-02T13:25:47+02:00", "port": "8288" }, "invoice-ninja": { @@ -2378,7 +2378,7 @@ "category": "finance", "logo": "svgs/invoiceninja.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9000" }, "it-tools": { @@ -2410,7 +2410,7 @@ "category": "media", "logo": "svgs/jellyfin.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:58:05+02:00", "port": "8096" }, "jenkins": { @@ -2443,7 +2443,7 @@ "category": "productivity", "logo": "svgs/jitsi.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-24T09:40:01+05:30", "port": "80" }, "joomla-with-mariadb": { @@ -2672,7 +2672,7 @@ "category": "ai", "logo": "svgs/langfuse.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-28T16:21:57+01:00", + "template_last_updated_at": "2026-04-23T18:08:40+02:00", "port": "3000" }, "leantime": { @@ -2713,7 +2713,7 @@ "category": "ai", "logo": "svgs/librechat.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-03T18:29:44+05:30", "port": "3080" }, "libreoffice": { @@ -2881,7 +2881,7 @@ "category": "auth", "logo": "svgs/logto_dark.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00" + "template_last_updated_at": "2026-04-01T13:19:47Z" }, "lowcoder": { "documentation": "https://docs.lowcoder.cloud/?utm_source=coolify.io", @@ -3005,7 +3005,7 @@ "category": "messaging", "logo": "svgs/mattermost.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-09-04T16:02:48+03:30", + "template_last_updated_at": "2026-04-06T13:57:40-05:00", "port": "8065" }, "mealie": { @@ -3021,7 +3021,7 @@ "category": "productivity", "logo": "svgs/mealie.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T09:22:15+02:00", "port": "9000" }, "mediawiki": { @@ -3149,7 +3149,7 @@ "category": "games", "logo": "svgs/minecraft.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-01T15:06:17-05:00", "port": "25565" }, "miniflux": { @@ -3166,7 +3166,7 @@ "category": "RSS", "logo": "svgs/miniflux.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "mixpost": { @@ -3242,7 +3242,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-03-08T01:08:18-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "n8n-with-postgresql": { @@ -3261,7 +3261,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-27T22:41:52-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "n8n": { @@ -3280,7 +3280,7 @@ "category": "automation", "logo": "svgs/n8n.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-27T22:41:52-05:00", + "template_last_updated_at": "2026-03-30T20:40:07+02:00", "port": "5678" }, "navidrome": { @@ -3328,7 +3328,7 @@ "category": "vpn", "logo": "svgs/netbird.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-16T10:33:10+05:30" + "template_last_updated_at": "2026-04-09T00:00:38+05:30" }, "newapi": { "documentation": "https://docs.newapi.pro/en/getting-started/?utm_source=coolify.io", @@ -3360,7 +3360,7 @@ "category": "proxy", "logo": "svgs/pangolin-logo.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-12T11:59:36-03:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "next-image-transformation": { "documentation": "https://github.com/coollabsio/next-image-transformation?utm_source=coolify.io", @@ -3392,7 +3392,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud-with-mysql": { @@ -3409,7 +3409,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud-with-postgres": { @@ -3426,7 +3426,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nextcloud": { @@ -3443,7 +3443,7 @@ "category": "storage", "logo": "svgs/nextcloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-07T23:32:57+05:30", "port": "80" }, "nexus-arm": { @@ -3554,7 +3554,7 @@ "category": "productivity", "logo": "svgs/nocodb.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "nodebb": { @@ -3621,7 +3621,7 @@ "category": "productivity", "logo": "svgs/odoo.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8069" }, "ollama-with-open-webui": { @@ -3712,7 +3712,7 @@ "category": "email", "logo": "svgs/openarchiver.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T23:41:36+01:00", + "template_last_updated_at": "2026-05-09T19:35:15+05:30", "port": "3000" }, "open-webui": { @@ -3770,7 +3770,7 @@ "category": "monitoring", "logo": "svgs/openobserve.svg", "minversion": "0.0.0", - "template_last_updated_at": null, + "template_last_updated_at": "2026-05-19T16:40:18+05:30", "port": "5080" }, "openpanel": { @@ -3789,7 +3789,7 @@ "category": "analytics", "logo": "svgs/openpanel.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-27T20:48:57+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "opnform": { @@ -3810,7 +3810,7 @@ "category": "productivity", "logo": "svg/opnform.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-24T10:13:08+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "orangehrm": { @@ -3828,7 +3828,7 @@ "category": "productivity", "logo": "svgs/orangehrm.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "organizr": { @@ -3857,7 +3857,7 @@ "category": "helpdesk", "logo": "svgs/osticket.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "overseerr": { @@ -3891,7 +3891,7 @@ "category": "storage", "logo": "svgs/owncloud.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-06-02T12:12:42+03:00", "port": "8080" }, "pairdrop": { @@ -3918,7 +3918,7 @@ "category": "games", "logo": "svgs/default.webp", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-15T21:55:50+01:00" + "template_last_updated_at": "2026-04-06T11:35:16-05:00" }, "paperless": { "documentation": "https://docs.paperless-ngx.com/configuration/?utm_source=coolify.io", @@ -3959,7 +3959,7 @@ "category": "finance", "logo": "svgs/paymenter.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-11-23T12:08:21+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "80" }, "penpot-with-s3": { @@ -4075,7 +4075,7 @@ "category": "productivity", "logo": "svgs/plane.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-24T02:34:35+05:30", + "template_last_updated_at": "2026-04-24T00:12:17+05:30", "port": "80" }, "plex": { @@ -4108,7 +4108,7 @@ "category": "email", "logo": "svgs/plunk.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "pocket-id-with-postgresql": { @@ -4265,7 +4265,7 @@ "category": "proxy", "logo": "svgs/hoppscotch.png", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-13T19:24:05+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "9159" }, "qbittorrent": { @@ -4351,7 +4351,7 @@ "category": "productivity", "logo": "svgs/rallly.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-03-19T16:11:50+01:00", "port": "3000" }, "reactive-resume": { @@ -4428,7 +4428,7 @@ "category": "productivity", "logo": "svgs/redmine.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:56+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "rivet-engine": { @@ -4446,7 +4446,7 @@ "category": "development", "logo": "svgs/rivet.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-10-22T16:07:42+02:00", + "template_last_updated_at": "2026-04-01T19:58:30+02:00", "port": "6420" }, "rocketchat": { @@ -4502,7 +4502,7 @@ "category": "productivity", "logo": "svgs/ryot.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-05-12T08:34:57+02:00", "port": "8000" }, "satisfactory": { @@ -4621,7 +4621,7 @@ "category": "storage", "logo": "svgs/sftpgo.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-12T23:44:06+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "shlink": { @@ -4660,7 +4660,7 @@ "category": "monitoring", "logo": "svgs/signoz.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-11T17:51:10+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "silverbullet": { @@ -4675,7 +4675,7 @@ "category": "productivity", "logo": "svgs/silverbullet.png", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-05T15:42:12+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "siyuan": { @@ -4759,7 +4759,7 @@ "category": "devtools", "logo": "svgs/soketi-app-manager.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-16T22:27:17+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" }, "soketi": { @@ -4898,7 +4898,7 @@ "category": "backend", "logo": "svgs/supabase.svg", "minversion": "4.0.0-beta.228", - "template_last_updated_at": "2026-01-20T19:10:07+01:00", + "template_last_updated_at": "2026-04-05T20:21:11+02:00", "port": "8000" }, "superset-with-postgresql": { @@ -5176,7 +5176,7 @@ "category": "productivity", "logo": "svgs/twenty.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-08T18:51:23+01:00", + "template_last_updated_at": "2026-04-16T23:18:19+05:30", "port": "3000" }, "typesense": { @@ -5224,7 +5224,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-01-04T19:45:35+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4242" }, "unleash-without-database": { @@ -5241,7 +5241,7 @@ "category": "devtools", "logo": "svgs/unleash.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-08-17T18:23:57+02:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "4242" }, "unstructured": { @@ -5339,7 +5339,7 @@ "category": "email", "logo": "svgs/usesend.svg", "minversion": "0.0.0", - "template_last_updated_at": "2025-12-07T17:32:23+11:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "3000" }, "vaultwarden": { @@ -5774,7 +5774,7 @@ "category": "storage", "logo": "svgs/cells.svg", "minversion": "0.0.0", - "template_last_updated_at": "2026-02-13T18:47:54+01:00", + "template_last_updated_at": "2026-04-06T11:35:16-05:00", "port": "8080" } } diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 45101635b..0234a9539 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -996,6 +996,35 @@ }); }); +describe('service application lifecycle command escaping', function () { + test('service application deploy action escapes docker command arguments', function () { + $source = file_get_contents(app_path('Actions/Service/DeployServiceApplication.php')); + + expect($source)->toContain('escapeshellarg($workdir)') + ->and($source)->toContain('escapeshellarg($composeFile)') + ->and($source)->toContain('escapeshellarg($service->uuid)') + ->and($source)->toContain('escapeshellarg($composeServiceName)') + ->and($source)->toContain('escapeshellarg($service->destination->network)'); + }); + + test('service application restart and stop actions escape container names', function (string $path) { + $source = file_get_contents(app_path($path)); + + expect($source)->toContain('escapeshellarg($serviceApplication->name'); + })->with([ + 'restart action' => 'Actions/Service/RestartServiceApplication.php', + 'stop action' => 'Actions/Service/StopServiceApplication.php', + ]); + + test('service application logs endpoint escapes container name before docker helpers', function () { + $source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php')); + + expect($source)->toContain('$safeContainerName = escapeshellarg($containerName)') + ->and($source)->toContain('getContainerStatus($server, $safeContainerName)') + ->and($source)->toContain('getContainerLogs($server, $containerName, $lines)'); + }); +}); + describe('install/build/start command validation', function () { test('rejects semicolon injection in install_command', function () { $rules = sharedDataApplications(); diff --git a/tests/Feature/ServiceApplicationsApiTest.php b/tests/Feature/ServiceApplicationsApiTest.php index d4f12350e..d65967bd8 100644 --- a/tests/Feature/ServiceApplicationsApiTest.php +++ b/tests/Feature/ServiceApplicationsApiTest.php @@ -20,20 +20,13 @@ beforeEach(function () { Queue::fake(); - InstanceSettings::updateOrCreate(['id' => 0]); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); - $plainTextToken = Str::random(40); - $token = $this->user->tokens()->create([ - 'name' => 'test-token', - 'token' => hash('sha256', $plainTextToken), - 'abilities' => ['*'], - 'team_id' => $this->team->id, - ]); - $this->bearerToken = $token->getKey().'|'.$plainTextToken; + $this->bearerToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->server->settings->update([ @@ -46,6 +39,19 @@ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); }); +function createBearerTokenForServiceApplicationsApiTest(User $user, Team $team, array $abilities = ['*']): string +{ + $plainTextToken = Str::random(40); + $token = $user->tokens()->create([ + 'name' => 'test-token', + 'token' => hash('sha256', $plainTextToken), + 'abilities' => $abilities, + 'team_id' => $team->id, + ]); + + return $token->getKey().'|'.$plainTextToken; +} + function createServiceWithApplicationForApiTest(object $ctx): object { $service = Service::factory()->create([ @@ -108,6 +114,32 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service $response->assertStatus(404); $response->assertJsonFragment(['message' => 'Service not found.']); }); + + test('does not list applications from another team', function () { + $otherTeam = Team::factory()->create(); + $otherServer = Server::factory()->create(['team_id' => $otherTeam->id]); + $otherServer->settings->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'force_disabled' => false, + ]); + + $otherCtx = (object) [ + 'server' => $otherServer, + 'destination' => StandaloneDocker::where('server_id', $otherServer->id)->first(), + 'environment' => Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $otherTeam->id])->id, + ]), + ]; + $ctx = createServiceWithApplicationForApiTest($otherCtx); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson("/api/v1/services/{$ctx->service->uuid}/applications"); + + $response->assertStatus(404); + $response->assertJsonFragment(['message' => 'Service not found.']); + }); }); describe('GET /api/v1/services/{uuid}/applications/{app_uuid}', function () { @@ -149,7 +181,7 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service 'human_name' => 'x', ], ['Accept' => 'application/json', 'Content-Type' => 'application/json']); - $response->assertStatus(400); + $response->assertStatus(401); }); test('updates human_name', function () { @@ -191,13 +223,51 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service $response->assertStatus(422); expect((string) $response->json('errors.is_log_drain_enabled.0'))->toContain('Log drain'); }); + + test('read-only token cannot update a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $readOnlyToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['read']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$readOnlyToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}", [ + 'human_name' => 'Blocked', + ]); + + $response->assertStatus(403); + }); + + test('returns 409 for domain conflicts unless forced', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $ctx->serviceApplication->update(['fqdn' => 'http://used.example.com']); + $otherApplication = ServiceApplication::create([ + 'name' => 'admin', + 'service_id' => $ctx->service->id, + 'image' => 'nginx:alpine', + ]); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}", [ + 'url' => 'http://used.example.com', + ])->assertStatus(409); + + $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->patchJson("/api/v1/services/{$ctx->service->uuid}/applications/{$otherApplication->uuid}?force_domain_override=true", [ + 'url' => 'http://used.example.com', + ])->assertStatus(200); + + $otherApplication->refresh(); + expect($otherApplication->fqdn)->toBe('http://used.example.com'); + }); }); describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/restart', function () { test('returns 400 without valid token', function () { $response = $this->postJson('/api/v1/services/some-uuid/applications/some-app/restart'); - $response->assertStatus(400); + $response->assertStatus(401); }); test('queues restart for a service application', function () { @@ -214,6 +284,17 @@ function createServiceWithoutApplicationsForApiTest(object $ctx): Service }); describe('POST /api/v1/services/{uuid}/applications/{app_uuid}/start', function () { + test('write token cannot start a service application', function () { + $ctx = createServiceWithApplicationForApiTest($this); + $writeToken = createBearerTokenForServiceApplicationsApiTest($this->user, $this->team, ['write']); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$writeToken, + ])->postJson("/api/v1/services/{$ctx->service->uuid}/applications/{$ctx->serviceApplication->uuid}/start"); + + $response->assertStatus(403); + }); + test('queues deploy for a service application', function () { $ctx = createServiceWithApplicationForApiTest($this); From 9f1343427323b5fcfe565e4a95ddfccce58ffedd Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:00:40 +0200 Subject: [PATCH 4/4] fix(docker): escape container status commands Build container status commands with escaped container names in the Docker helper so callers can pass raw identifiers consistently. --- .../Api/ServiceApplicationsController.php | 3 +-- bootstrap/helpers/docker.php | 13 +++++++++---- .../Security/CommandInjectionSecurityTest.php | 16 ++++++++++++---- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/ServiceApplicationsController.php b/app/Http/Controllers/Api/ServiceApplicationsController.php index de07f2e33..a144b3ea6 100644 --- a/app/Http/Controllers/Api/ServiceApplicationsController.php +++ b/app/Http/Controllers/Api/ServiceApplicationsController.php @@ -455,9 +455,8 @@ public function logs_by_uuid(Request $request): JsonResponse } $containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid; - $safeContainerName = escapeshellarg($containerName); - $status = getContainerStatus($server, $safeContainerName); + $status = getContainerStatus($server, $containerName); if ($status !== 'running') { return response()->json([ 'message' => 'Service application container is not running.', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index e6643a337..0440ae352 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -178,13 +178,18 @@ function executeInDocker(string $containerId, string $command) return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } -function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +function buildContainerStatusCommand(Server $server, string $container_id): string { if ($server->isSwarm()) { - $container = instant_remote_process(["docker service ls --filter 'name={$container_id}' --format '{{json .}}' "], $server, $throwError); - } else { - $container = instant_remote_process(["docker inspect --format '{{json .}}' {$container_id}"], $server, $throwError); + return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' "; } + + return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id); +} + +function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) +{ + $container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError); if (! $container) { return 'exited'; } diff --git a/tests/Feature/Security/CommandInjectionSecurityTest.php b/tests/Feature/Security/CommandInjectionSecurityTest.php index 0234a9539..42c08c29d 100644 --- a/tests/Feature/Security/CommandInjectionSecurityTest.php +++ b/tests/Feature/Security/CommandInjectionSecurityTest.php @@ -1016,12 +1016,20 @@ 'stop action' => 'Actions/Service/StopServiceApplication.php', ]); - test('service application logs endpoint escapes container name before docker helpers', function () { + test('docker status helper escapes container names', function () { + $source = file_get_contents(base_path('bootstrap/helpers/docker.php')); + + expect($source)->toContain('function buildContainerStatusCommand') + ->and($source)->toContain('escapeshellarg("name={$container_id}")') + ->and($source)->toContain('escapeshellarg($container_id)'); + }); + + test('service application logs endpoint passes raw container name to docker helpers', function () { $source = file_get_contents(app_path('Http/Controllers/Api/ServiceApplicationsController.php')); - expect($source)->toContain('$safeContainerName = escapeshellarg($containerName)') - ->and($source)->toContain('getContainerStatus($server, $safeContainerName)') - ->and($source)->toContain('getContainerLogs($server, $containerName, $lines)'); + expect($source)->toContain('getContainerStatus($server, $containerName)') + ->and($source)->toContain('getContainerLogs($server, $containerName, $lines)') + ->and($source)->not->toContain('$safeContainerName = escapeshellarg($containerName)'); }); });