diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 8054462e9a..ce5110bfbd 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -15,6 +15,7 @@ use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\LocalFileVolume; use App\Models\LocalPersistentVolume; @@ -36,6 +37,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -789,6 +791,218 @@ class ApplicationsController extends Controller return $this->create_application($request, 'private-gh-app'); } + #[OA\Post( + summary: 'Create (Private - Gitea App)', + description: 'Create new application based on a private repository through a Gitea App.', + path: '/applications/private-gitea-app', + operationId: 'create-private-gitea-app-application', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + requestBody: new OA\RequestBody( + description: 'Application object that needs to be created.', + required: true, + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['project_uuid', 'server_uuid', 'gitea_app_uuid', 'git_repository', 'git_branch', 'build_pack'], + properties: [ + 'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'], + 'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'], + 'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'], + 'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'], + 'gitea_app_uuid' => ['type' => 'string', 'description' => 'The Gitea App UUID. The source must be connected.'], + 'git_repository' => ['type' => 'string', 'description' => 'The repository as owner/name, or its URL on the Gitea instance.'], + 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], + 'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'], + 'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'], + 'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'], + 'name' => ['type' => 'string', 'description' => 'The application name.'], + 'description' => ['type' => 'string', 'description' => 'The application description.'], + 'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'], + 'noindex_domains' => ['type' => 'array', 'items' => ['type' => 'string'], 'description' => 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.'], + 'git_commit_sha' => ['type' => 'string', 'description' => 'The git commit SHA.'], + 'docker_registry_image_name' => ['type' => 'string', 'description' => 'The docker registry image name.'], + 'docker_registry_image_tag' => ['type' => 'string', 'description' => 'The docker registry image tag.'], + 'is_static' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is static.'], + 'is_spa' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.'], + 'is_auto_deploy_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.'], + 'is_force_https_enabled' => ['type' => 'boolean', 'description' => 'The flag to indicate if HTTPS is forced. Defaults to true.'], + 'is_preview_deployments_enabled' => ['type' => 'boolean', 'description' => 'Enable preview deployments for pull requests.'], + 'static_image' => ['type' => 'string', 'enum' => ['nginx:alpine'], 'description' => 'The static image.'], + 'install_command' => ['type' => 'string', 'description' => 'The install command.'], + 'build_command' => ['type' => 'string', 'description' => 'The build command.'], + 'start_command' => ['type' => 'string', 'description' => 'The start command.'], + 'ports_mappings' => ['type' => 'string', 'description' => 'The ports mappings.'], + 'base_directory' => ['type' => 'string', 'description' => 'The base directory for all commands.'], + 'publish_directory' => ['type' => 'string', 'description' => 'The publish directory.'], + 'health_check_enabled' => ['type' => 'boolean', 'description' => 'Health check enabled.'], + 'health_check_path' => ['type' => 'string', 'description' => 'Health check path.'], + 'health_check_port' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check port.'], + 'health_check_host' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check host.'], + 'health_check_method' => ['type' => 'string', 'description' => 'Health check method.'], + 'health_check_return_code' => ['type' => 'integer', 'description' => 'Health check return code.'], + 'health_check_scheme' => ['type' => 'string', 'description' => 'Health check scheme.'], + 'health_check_response_text' => ['type' => 'string', 'nullable' => true, 'description' => 'Health check response text.'], + 'health_check_interval' => ['type' => 'integer', 'description' => 'Health check interval in seconds.'], + 'health_check_timeout' => ['type' => 'integer', 'description' => 'Health check timeout in seconds.'], + 'health_check_retries' => ['type' => 'integer', 'description' => 'Health check retries count.'], + 'health_check_start_period' => ['type' => 'integer', 'description' => 'Health check start period in seconds.'], + 'limits_memory' => ['type' => 'string', 'description' => 'Memory limit.'], + 'limits_memory_swap' => ['type' => 'string', 'description' => 'Memory swap limit.'], + 'limits_memory_swappiness' => ['type' => 'integer', 'description' => 'Memory swappiness.'], + 'limits_memory_reservation' => ['type' => 'string', 'description' => 'Memory reservation.'], + 'limits_cpus' => ['type' => 'string', 'description' => 'CPU limit.'], + 'limits_cpuset' => ['type' => 'string', 'nullable' => true, 'description' => 'CPU set.'], + 'limits_cpu_shares' => ['type' => 'integer', 'description' => 'CPU shares.'], + 'custom_labels' => ['type' => 'string', 'description' => 'Custom labels.'], + 'custom_docker_run_options' => ['type' => 'string', 'description' => 'Custom docker run options.'], + 'post_deployment_command' => ['type' => 'string', 'description' => 'Post deployment command.'], + 'post_deployment_command_container' => ['type' => 'string', 'description' => 'Post deployment command container.'], + 'pre_deployment_command' => ['type' => 'string', 'description' => 'Pre deployment command.'], + 'pre_deployment_command_container' => ['type' => 'string', 'description' => 'Pre deployment command container.'], + 'manual_webhook_secret_github' => ['type' => 'string', 'description' => 'Manual webhook secret for Github. Needs the deploy ability.'], + 'manual_webhook_secret_gitlab' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitlab. Needs the deploy ability.'], + 'manual_webhook_secret_bitbucket' => ['type' => 'string', 'description' => 'Manual webhook secret for Bitbucket. Needs the deploy ability.'], + 'manual_webhook_secret_gitea' => ['type' => 'string', 'description' => 'Manual webhook secret for Gitea. Needs the deploy ability.'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'How to set redirect with Traefik / Caddy. www<->non-www.', 'enum' => ['www', 'non-www', 'both']], + 'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'], + 'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'], + 'dockerfile_location' => ['type' => 'string', 'description' => 'The Dockerfile location in the repository'], + 'dockerfile_target_build' => ['type' => 'string', 'nullable' => true, 'description' => 'The Docker build target (stage) in the Dockerfile.'], + 'docker_compose_location' => ['type' => 'string', 'description' => 'The Docker Compose location.'], + 'docker_compose_custom_start_command' => ['type' => 'string', 'description' => 'The Docker Compose custom start command.'], + 'docker_compose_custom_build_command' => ['type' => 'string', 'description' => 'The Docker Compose custom build command.'], + 'docker_compose_domains' => [ + 'type' => 'array', + 'description' => 'Array of URLs to be applied to containers of a dockercompose application.', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'], + 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'], + 'redirect' => ['type' => 'string', 'nullable' => true, 'description' => 'Per-service www/non-www redirect for this compose service.', 'enum' => ['www', 'non-www', 'both']], + ], + ), + ], + 'watch_paths' => ['type' => 'string', 'description' => 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.'], + 'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'], + 'use_build_secrets' => ['type' => 'boolean', 'default' => false, 'description' => 'Use Docker Build Secrets for build-time environment variables.'], + 'is_git_submodules_enabled' => ['type' => 'boolean', 'description' => 'Clone Git submodules.'], + 'is_git_lfs_enabled' => ['type' => 'boolean', 'description' => 'Enable Git LFS.'], + 'is_git_shallow_clone_enabled' => ['type' => 'boolean', 'description' => 'Use a shallow Git clone.'], + 'is_auto_cancel_queued_deployments_enabled' => ['type' => 'boolean', 'description' => 'Cancel older queued webhook deployments when a newer push arrives. Default: true.'], + 'is_auto_cancel_building_deployments_enabled' => ['type' => 'boolean', 'description' => 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.'], + 'disable_build_cache' => ['type' => 'boolean', 'description' => 'Disable the build cache.'], + 'inject_build_args_to_dockerfile' => ['type' => 'boolean', 'description' => 'Inject build arguments into the Dockerfile build.'], + 'include_source_commit_in_build' => ['type' => 'boolean', 'description' => 'Include the source commit in the build.'], + 'is_env_sorting_enabled' => ['type' => 'boolean', 'description' => 'Sort environment variables.'], + 'redact_all_env_values_in_logs' => ['type' => 'boolean', 'description' => 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.'], + 'include_coolify_default_variables' => ['type' => 'boolean', 'default' => true, 'description' => 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.'], + 'is_pr_deployments_public_enabled' => ['type' => 'boolean', 'description' => 'Make pull request deployments public.'], + 'stop_grace_period' => ['type' => 'integer', 'nullable' => true, 'minimum' => 1, 'maximum' => 3600, 'description' => 'Container stop grace period in seconds.'], + 'rolling_update_drain_seconds' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 300, 'description' => 'Seconds to wait after the new container is healthy before old containers stop during a rolling update. 0 turns the wait off. Default: 5.'], + 'docker_images_to_keep' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100, 'description' => 'Number of Docker images to retain.'], + 'is_gzip_enabled' => ['type' => 'boolean', 'description' => 'Enable gzip compression.'], + 'is_stripprefix_enabled' => ['type' => 'boolean', 'description' => 'Enable path prefix stripping.'], + 'is_permanent_redirect_enabled' => ['type' => 'boolean', 'description' => 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.'], + 'traefik_certificate_resolver' => ['type' => 'string', 'nullable' => true, 'description' => 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.'], + 'is_container_label_readonly_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.'], + 'is_raw_compose_deployment_enabled' => ['type' => 'boolean', 'description' => 'Deploy the raw Docker Compose definition.'], + 'is_log_drain_enabled' => ['type' => 'boolean', 'description' => 'Enable log drain for the application.'], + 'is_gpu_enabled' => ['type' => 'boolean', 'description' => 'Enable GPU support.'], + 'gpu_driver' => ['type' => 'string', 'nullable' => true, 'description' => 'GPU driver name.'], + 'gpu_count' => ['type' => 'string', 'nullable' => true, 'description' => 'Number of GPUs to allocate.'], + 'gpu_device_ids' => ['type' => 'string', 'nullable' => true, 'description' => 'Comma-separated GPU device IDs.'], + 'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'], + 'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'], + 'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.'], + 'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'], + 'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'], + 'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'], + 'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'], + 'http_basic_auth_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTP Basic Authentication'], + 'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'], + 'http_basic_auth_hash_algorithm' => ['type' => 'string', 'enum' => ['bcrypt', 'argon2id'], 'default' => 'bcrypt', 'description' => 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.'], + 'http_basic_auth_bcrypt_cost' => ['type' => 'integer', 'minimum' => 4, 'maximum' => 14, 'default' => 10, 'description' => 'Bcrypt cost for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_memory_cost' => ['type' => 'integer', 'minimum' => 8192, 'maximum' => 262144, 'default' => 65536, 'description' => 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.'], + 'http_basic_auth_argon2id_time_cost' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 12, 'default' => 4, 'description' => 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.'], + 'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'], + 'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'], + 'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true.'], + 'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'], + 'tags' => ['type' => 'array', 'items' => new OA\Items(type: 'string'), 'description' => 'Tags to assign to the application.'], + 'is_preserve_repository_enabled' => ['type' => 'boolean', 'default' => false, 'description' => 'Preserve repository during deployment.'], + ], + ) + ), + ] + ), + responses: [ + new OA\Response( + response: 201, + description: 'Application created successfully.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'uuid' => ['type' => 'string'], + ] + ) + ) + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 403, + description: 'The token needs the deploy ability to set a manual webhook secret.', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 409, + description: 'Domain conflicts detected.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'], + 'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'], + 'conflicts' => [ + 'type' => 'array', + 'items' => new OA\Schema( + type: 'object', + properties: [ + 'domain' => ['type' => 'string', 'example' => 'example.com'], + 'resource_name' => ['type' => 'string', 'example' => 'My Application'], + 'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'], + 'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'], + 'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''], + ] + ), + ], + ] + ) + ), + ] + ), + ] + )] + public function create_private_gitea_app_application(Request $request) + { + return $this->create_application($request, 'private-gitea-app'); + } + #[OA\Post( summary: 'Create (Private - Deploy Key)', description: 'Create new application based on a private repository through a Deploy Key.', @@ -1396,7 +1610,7 @@ class ApplicationsController extends Controller 'autogenerate_domain' => 'boolean', 'tags' => 'array|nullable', 'tags.*' => 'string|min:2', - ]); + ], ValidationPatterns::combinedMessages()); $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { @@ -1458,7 +1672,6 @@ class ApplicationsController extends Controller $autogenerateDomain = $request->boolean('autogenerate_domain', true) && $request->build_pack !== 'dockercompose'; $forceDomainOverride = $request->boolean('force_domain_override'); $instantDeploy = $request->instant_deploy; - $githubAppUuid = $request->github_app_uuid; $useBuildServer = $request->use_build_server; $useBuildSecrets = $request->use_build_secrets; $isStatic = $request->is_static; @@ -1472,7 +1685,7 @@ class ApplicationsController extends Controller $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false); $applicationSettings = $this->applicationSettingsFromRequest($request); - $requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-deploy-key'], true) + $requestedBuildPack = in_array($type, ['public', 'private-gh-app', 'private-gitea-app', 'private-deploy-key'], true) ? $request->input('build_pack') : $type; if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') { @@ -1804,13 +2017,14 @@ class ApplicationsController extends Controller 'uuid' => data_get($application, 'uuid'), 'domains' => data_get($application, 'fqdn'), ]))->setStatusCode(201); - } elseif ($type === 'private-gh-app') { + } elseif (in_array($type, ['private-gh-app', 'private-gitea-app'], true)) { + $sourceUuidField = $type === 'private-gh-app' ? 'github_app_uuid' : 'gitea_app_uuid'; $validationRules = [ 'git_repository' => 'string|required', 'git_branch' => ['string', 'required', new ValidGitBranch], 'build_pack' => ['required', Rule::enum(BuildPackTypes::class)], 'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable', - 'github_app_uuid' => 'string|required', + $sourceUuidField => 'string|required', 'watch_paths' => 'string|nullable', 'docker_compose_domains' => 'array|nullable', 'docker_compose_domains.*' => 'array:name,domain,redirect', @@ -1851,39 +2065,13 @@ class ApplicationsController extends Controller if ($return instanceof JsonResponse) { return $return; } - $githubApp = GithubApp::where('uuid', $githubAppUuid) - ->where(function ($query) use ($teamId) { - $query->where('team_id', $teamId) - ->orWhere('is_system_wide', true); - }) - ->first(); - if (! $githubApp) { - return response()->json(['message' => 'Github App not found.'], 404); + $sourceRepository = $type === 'private-gh-app' + ? $this->githubAppRepository($request->github_app_uuid, $request->git_repository, $teamId) + : $this->giteaAppRepository($this->teamSourceByUuid(GiteaApp::class, $request->gitea_app_uuid, $teamId), $request->git_repository); + if ($sourceRepository instanceof JsonResponse) { + return $sourceRepository; } - $token = generateGithubInstallationToken($githubApp); - if (! $token) { - return response()->json(['message' => 'Failed to generate Github App token.'], 400); - } - - $gitRepository = gitRepositorySlug($request->git_repository); - - // Use direct API call to verify repository access instead of loading all repositories - // This is much faster and avoids timeouts for GitHub Apps with many repositories - $response = Http::GitHub($githubApp->api_url, $token) - ->timeout(20) - ->retry(3, 200, throw: false) - ->get("/repos/{$gitRepository}"); - - if ($response->status() === 404 || $response->status() === 403) { - return response()->json(['message' => 'Repository not found or not accessible by the GitHub App.'], 404); - } - - if (! $response->successful()) { - return response()->json(['message' => 'Failed to verify repository access: '.($response->json()['message'] ?? 'Unknown error')], 400); - } - - $gitRepositoryFound = $response->json(); - $repository_project_id = data_get($gitRepositoryFound, 'id'); + ['source' => $source, 'repository' => $gitRepository, 'repository_project_id' => $repository_project_id] = $sourceRepository; $application = new Application; removeUnnecessaryFieldsFromRequest($request); @@ -1970,8 +2158,8 @@ class ApplicationsController extends Controller $application->destination_id = $destination->id; $application->destination_type = $destination->getMorphClass(); $application->environment_id = $environment->id; - $application->source_type = $githubApp->getMorphClass(); - $application->source_id = $githubApp->id; + $application->source_type = $source->getMorphClass(); + $application->source_id = $source->id; $application->repository_project_id = $repository_project_id; if ($imageNameValidation = $this->sharedImageNameValidationResponse($application)) { @@ -3230,6 +3418,7 @@ class ApplicationsController extends Controller type: 'object', properties: [ 'github_app_uuid' => ['type' => 'string', 'description' => 'The Github App UUID.'], + 'gitea_app_uuid' => ['type' => 'string', 'description' => 'The Gitea App UUID. The source must be connected. Do not send it together with github_app_uuid.'], 'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'], 'git_branch' => ['type' => 'string', 'description' => 'The git branch.'], 'git_https_username' => ['type' => 'string', 'nullable' => true, 'description' => 'Username for HTTPS access to a private repository on a custom Git host. Send null to remove it.'], @@ -3471,6 +3660,7 @@ class ApplicationsController extends Controller ]; $validationRules = array_merge(sharedDataApplications($application), $validationRules); $validationMessages = [ + ...ValidationPatterns::combinedMessages(), 'docker_compose_domains.*.array' => 'An item in the docker_compose_domains array has invalid fields. Only name, domain, and redirect fields are supported.', ]; $validator = Validator::make($request->all(), $validationRules, $validationMessages); @@ -3551,39 +3741,36 @@ class ApplicationsController extends Controller 'errors' => $errors, ], 422); } - $githubApp = null; - if ($request->has('github_app_uuid')) { + $newSource = null; + if ($request->has('github_app_uuid') && $request->has('gitea_app_uuid')) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['gitea_app_uuid' => 'Send only one of github_app_uuid or gitea_app_uuid.'], + ], 422); + } + foreach (['github_app_uuid' => GithubApp::class, 'gitea_app_uuid' => GiteaApp::class] as $sourceUuidField => $sourceClass) { + if (! $request->has($sourceUuidField)) { + continue; + } if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') { return response()->json([ 'message' => 'Validation failed.', 'errors' => [ - 'github_app_uuid' => 'The github_app_uuid can only be changed for applications deployed from a GitHub App or public repository.', + $sourceUuidField => "The {$sourceUuidField} can only be changed for applications deployed from a Git source or public repository.", ], ], 422); } - $githubApp = GithubApp::where('uuid', $request->github_app_uuid) - ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) - ->first(); - if (! $githubApp?->isConnected()) { - return response()->json(['message' => 'Github App not found.'], 404); + $newSource = $this->teamSourceByUuid($sourceClass, $request->input($sourceUuidField), $teamId); + if (! $newSource?->isConnected()) { + return response()->json(['message' => $sourceClass === GithubApp::class ? 'Github App not found.' : 'Gitea App not found.'], 404); } } - - $githubApp = null; - if ($request->has('github_app_uuid')) { - if ($application->build_pack === 'dockerimage' || filled($application->dockerfile) || $application->deploymentType() === 'deploy_key') { - return response()->json([ - 'message' => 'Validation failed.', - 'errors' => [ - 'github_app_uuid' => 'The github_app_uuid can only be changed for applications deployed from a GitHub App or public repository.', - ], - ], 422); - } - $githubApp = GithubApp::where('uuid', $request->github_app_uuid) - ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) - ->first(); - if (! $githubApp?->isConnected()) { - return response()->json(['message' => 'Github App not found.'], 404); + $giteaRepository = null; + if ($newSource instanceof GiteaApp) { + // Gitea clones and webhooks need owner/name and the repository ID of the new source. + $giteaRepository = $this->giteaAppRepository($newSource, (string) $request->input('git_repository', $application->git_repository)); + if ($giteaRepository instanceof JsonResponse) { + return $giteaRepository; } } @@ -3871,12 +4058,16 @@ class ApplicationsController extends Controller $application->git_https_username = null; } } - if ($githubApp && ($application->source_id !== $githubApp->id || $application->source_type !== $githubApp->getMorphClass())) { - $application->source_id = $githubApp->id; - $application->source_type = $githubApp->getMorphClass(); - // The next deployment fetches the repository ID from the new source. + if ($newSource && ($application->source_id !== $newSource->id || $application->source_type !== $newSource->getMorphClass())) { + $application->source_id = $newSource->id; + $application->source_type = $newSource->getMorphClass(); + // The next deployment fetches the repository ID from a GitHub App. $application->repository_project_id = null; } + if ($giteaRepository) { + $application->git_repository = $giteaRepository['repository']; + $application->repository_project_id = $giteaRepository['repository_project_id']; + } if ($requestHasNoindexDomains) { // Must run after fqdn is filled: flags are kept only for domains the app still has. $application->setNoindexDomains($request->input('noindex_domains') ?? []); @@ -5092,6 +5283,120 @@ class ApplicationsController extends Controller return migrateResourceToDestination($request, $application, 'Application', $teamId); } + /** + * Find a Git source of the team, or a system-wide one, by UUID. + * + * @template TSource of GithubApp|GiteaApp + * + * @param class-string $sourceClass + * @return TSource|null + */ + private function teamSourceByUuid(string $sourceClass, string $uuid, int|string $teamId): GithubApp|GiteaApp|null + { + return $sourceClass::where('uuid', $uuid) + ->where(fn ($query) => $query->where('team_id', $teamId)->orWhere('is_system_wide', true)) + ->first(); + } + + /** + * Find a GitHub App of the team and check that it can read the repository. + * + * @return array{source: GithubApp, repository: string, repository_project_id: mixed}|JsonResponse + */ + private function githubAppRepository(string $githubAppUuid, string $gitRepository, int|string $teamId): array|JsonResponse + { + $githubApp = $this->teamSourceByUuid(GithubApp::class, $githubAppUuid, $teamId); + if (! $githubApp) { + return response()->json(['message' => 'Github App not found.'], 404); + } + $token = generateGithubInstallationToken($githubApp); + if (! $token) { + return response()->json(['message' => 'Failed to generate Github App token.'], 400); + } + + $gitRepository = gitRepositorySlug($gitRepository); + + // Use direct API call to verify repository access instead of loading all repositories + // This is much faster and avoids timeouts for GitHub Apps with many repositories + $response = Http::GitHub($githubApp->api_url, $token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$gitRepository}"); + + if ($response->status() === 404 || $response->status() === 403) { + return response()->json(['message' => 'Repository not found or not accessible by the GitHub App.'], 404); + } + + if (! $response->successful()) { + return response()->json(['message' => 'Failed to verify repository access: '.($response->json()['message'] ?? 'Unknown error')], 400); + } + + return ['source' => $githubApp, 'repository' => $gitRepository, 'repository_project_id' => data_get($response->json(), 'id')]; + } + + /** + * Check that a connected Gitea App can read the repository. + * + * @return array{source: GiteaApp, repository: string, repository_project_id: mixed}|JsonResponse + */ + private function giteaAppRepository(?GiteaApp $giteaApp, string $gitRepository): array|JsonResponse + { + if (! $giteaApp?->isConnected()) { + return response()->json(['message' => 'Gitea App not found.'], 404); + } + + $repositoryPath = $this->giteaRepositoryPath($giteaApp, $gitRepository); + if ($repositoryPath === null) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['git_repository' => 'The git_repository must be owner/name or a repository URL on the Gitea instance of the source.'], + ], 422); + } + + try { + refreshGiteaToken($giteaApp); + } catch (\RuntimeException $e) { + return response()->json(['message' => $e->getMessage()], 400); + } + + $response = Http::Gitea($giteaApp->apiUrlBase(), $giteaApp->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/repos/'.collect(explode('/', $repositoryPath))->map(fn (string $part) => rawurlencode($part))->implode('/')); + + if ($response->status() === 404 || $response->status() === 403 + || ($response->successful() && ! $giteaApp->allowsRepositoryOwner(data_get($response->json(), 'owner.login')))) { + return response()->json(['message' => 'Repository not found or not accessible by the Gitea App.'], 404); + } + + if (! $response->successful()) { + return response()->json(['message' => 'Failed to verify repository access: '.data_get($response->json(), 'message', 'Unknown error')], 400); + } + + return ['source' => $giteaApp, 'repository' => $repositoryPath, 'repository_project_id' => data_get($response->json(), 'id')]; + } + + /** + * Get owner/name from owner/name, or from an HTTPS or SSH URL on the Gitea instance of the source. + */ + private function giteaRepositoryPath(GiteaApp $giteaApp, string $gitRepository): ?string + { + $repository = sshGitUrlToHttps($gitRepository) ?? $gitRepository; + if (str_contains($repository, '://')) { + if (strtolower((string) parse_url($repository, PHP_URL_HOST)) !== strtolower((string) parse_url($giteaApp->html_url, PHP_URL_HOST))) { + return null; + } + $repository = Str::chopStart((string) parse_url($repository, PHP_URL_PATH), rtrim((string) parse_url($giteaApp->html_url, PHP_URL_PATH), '/')); + } + $repository = str($repository)->trim('/')->replaceEnd('.git', '')->toString(); + + if (! preg_match('#^[\w.-]+/[\w.-]+$#', $repository) || array_intersect(explode('/', $repository), ['.', '..']) !== []) { + return null; + } + + return $repository; + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); @@ -6105,9 +6410,9 @@ class ApplicationsController extends Controller $validator = customApiValidator($request->all(), [ 'destination_uuid' => 'required|string', - 'name' => 'string|max:255|nullable', + 'name' => ValidationPatterns::nameRules(required: false), 'clone_volumes' => 'boolean', - ]); + ], ValidationPatterns::combinedMessages()); $allowedFields = ['destination_uuid', 'name', 'clone_volumes']; $extraFields = array_diff(array_keys($request->all()), $allowedFields); if ($validator->fails() || ! empty($extraFields)) { diff --git a/app/Http/Controllers/Api/GiteaController.php b/app/Http/Controllers/Api/GiteaController.php new file mode 100644 index 0000000000..43e79238ba --- /dev/null +++ b/app/Http/Controllers/Api/GiteaController.php @@ -0,0 +1,94 @@ +attributes->get('can_read_sensitive', false) === true && $giteaApp->team_id === $teamId) { + $giteaApp->makeVisible($sensitiveFields); + } else { + $giteaApp->makeHidden($sensitiveFields); + } + + return serializeApiResponse($giteaApp); + } + + #[OA\Get( + summary: 'List', + description: 'List all Gitea apps for the current team (and system-wide sources).', + path: '/gitea-apps', + operationId: 'list-gitea-apps', + security: [ + ['bearerAuth' => []], + ], + tags: ['Gitea Apps'], + responses: [ + new OA\Response( + response: 200, + description: 'List of Gitea apps.', + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'array', + items: new OA\Items( + type: 'object', + properties: [ + 'id' => ['type' => 'integer'], + 'uuid' => ['type' => 'string'], + 'name' => ['type' => 'string'], + 'organization' => ['type' => 'string', 'nullable' => true], + 'api_url' => ['type' => 'string'], + 'html_url' => ['type' => 'string'], + 'custom_user' => ['type' => 'string'], + 'custom_port' => ['type' => 'integer'], + 'client_id' => ['type' => 'string', 'nullable' => true], + 'redirect_uri' => ['type' => 'string', 'nullable' => true], + 'is_system_wide' => ['type' => 'boolean'], + 'is_public' => ['type' => 'boolean'], + 'is_connected' => ['type' => 'boolean', 'description' => 'True when the OAuth connection is complete and the source can deploy private repositories.'], + 'team_id' => ['type' => 'integer'], + ] + ) + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + ] + )] + public function list_gitea_apps(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $giteaApps = GiteaApp::where(function ($query) use ($teamId) { + $query->where('team_id', $teamId) + ->orWhere('is_system_wide', true); + })->get(); + + return response()->json($giteaApps->map( + fn (GiteaApp $giteaApp) => $this->removeSensitiveData($giteaApp, $teamId)->put('is_connected', $giteaApp->isConnected()) + )); + } +} diff --git a/app/Mcp/Tools/CreateApplication.php b/app/Mcp/Tools/CreateApplication.php index 82c6413f8a..0181aa4e95 100644 --- a/app/Mcp/Tools/CreateApplication.php +++ b/app/Mcp/Tools/CreateApplication.php @@ -11,13 +11,14 @@ class CreateApplication extends RestActionTool { protected string $name = 'create_application'; - protected string $description = 'Create an application in a project of the authenticated team, from a public Git repository, a private repository (GitHub App or deploy key), a Dockerfile, or a Docker image. Requires write ability (instant_deploy and manual webhook secrets also need deploy). Same validation and permissions as the REST API.'; + protected string $description = 'Create an application in a project of the authenticated team, from a public Git repository, a private repository (GitHub App, Gitea App, or deploy key), a Dockerfile, or a Docker image. Requires write ability (instant_deploy and manual webhook secrets also need deploy). Same validation and permissions as the REST API.'; protected function actions(): array { return [ 'public' => ['method' => 'POST', 'path' => 'applications/public', 'ability' => 'write', 'description' => 'create from a public Git repository (data: git_repository, git_branch, build_pack, ports_exposes)'], 'github_app' => ['method' => 'POST', 'path' => 'applications/private-github-app', 'ability' => 'write', 'description' => 'create from a private repository through a GitHub App (data: github_app_uuid, git_repository, git_branch, build_pack, ports_exposes)'], + 'gitea_app' => ['method' => 'POST', 'path' => 'applications/private-gitea-app', 'ability' => 'write', 'description' => 'create from a private repository through a connected Gitea App (data: gitea_app_uuid, git_repository as owner/name, git_branch, build_pack, ports_exposes)'], 'deploy_key' => ['method' => 'POST', 'path' => 'applications/private-deploy-key', 'ability' => 'write', 'description' => 'create from a private repository with a deploy key (data: private_key_uuid, git_repository, git_branch, build_pack, ports_exposes)'], 'dockerfile' => ['method' => 'POST', 'path' => 'applications/dockerfile', 'ability' => 'write', 'description' => 'create from a base64 encoded Dockerfile (data: dockerfile, ports_exposes)'], 'docker_image' => ['method' => 'POST', 'path' => 'applications/dockerimage', 'ability' => 'write', 'description' => 'create from a Docker image (data: docker_registry_image_name, docker_registry_image_tag, ports_exposes)'], @@ -32,7 +33,7 @@ class CreateApplication extends RestActionTool protected function dataDescription(): string { return 'Request body, same fields as the REST API. Always: project_uuid, environment_name or environment_uuid, server_uuid, destination_uuid (required when the server has several destinations). ' - .'public / github_app / deploy_key: git_repository, git_branch, build_pack (nixpacks | railpack | static | dockerfile | dockercompose), ports_exposes (not for dockercompose); github_app also needs github_app_uuid, deploy_key needs private_key_uuid. ' + .'public / github_app / gitea_app / deploy_key: git_repository, git_branch, build_pack (nixpacks | railpack | static | dockerfile | dockercompose), ports_exposes (not for dockercompose); github_app also needs github_app_uuid, gitea_app needs gitea_app_uuid, deploy_key needs private_key_uuid. ' .'dockerfile: dockerfile (base64). docker_image: docker_registry_image_name, docker_registry_image_tag, ports_exposes. ' .'Optional: name, description, domains, autogenerate_domain, git_commit_sha, base_directory, publish_directory, install_command, build_command, start_command, docker_compose_location, docker_compose_domains, is_static, is_spa, health_check_*, limits_*, tags, instant_deploy (needs deploy ability).'; } diff --git a/app/Mcp/Tools/ManageGitSource.php b/app/Mcp/Tools/ManageGitSource.php index ce20ed1978..54846eda03 100644 --- a/app/Mcp/Tools/ManageGitSource.php +++ b/app/Mcp/Tools/ManageGitSource.php @@ -11,12 +11,12 @@ class ManageGitSource extends RestActionTool { protected string $name = 'manage_git_source'; - protected string $description = 'List, add, update, or delete GitHub apps (resource=github) and GitLab apps (resource=gitlab) of the authenticated team. Secrets and keys are never returned. Requires read ability for list, write ability for changes. Same validation and permissions as the REST API.'; + protected string $description = 'List, add, update, or delete GitHub apps (resource=github) and GitLab apps (resource=gitlab) of the authenticated team. Gitea apps (resource=gitea) can only be listed; add and connect them in the UI. Secrets and keys are never returned. Requires read ability for list, write ability for changes. Same validation and permissions as the REST API.'; protected function actions(): array { return [ - 'list' => ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], + 'list' => ['method' => 'GET', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps', 'gitea' => 'gitea-apps'], 'ability' => 'read', 'description' => 'git apps of the team and system-wide apps'], 'create' => ['method' => 'POST', 'paths' => ['github' => 'github-apps', 'gitlab' => 'gitlab-apps'], 'ability' => 'write', 'description' => 'add a git app (github data: name, html_url, api_url, app_id, installation_id, client_id, client_secret, webhook_secret, private_key_uuid, organization, custom_user, custom_port; gitlab data: name, html_url, api_url, group_name, client_id, client_secret, webhook_token, redirect_uri, custom_user, custom_port)'], 'update' => ['method' => 'PATCH', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'write', 'description' => 'update a git app of the team (data: same fields as create, all optional)'], 'delete' => ['method' => 'DELETE', 'paths' => ['github' => 'github-apps/{source_id}', 'gitlab' => 'gitlab-apps/{source_id}'], 'ability' => 'delete', 'confirm' => true, 'description' => 'delete a git app of the team that no application uses'], diff --git a/app/Models/GiteaApp.php b/app/Models/GiteaApp.php index cee2c41a58..82637c861e 100644 --- a/app/Models/GiteaApp.php +++ b/app/Models/GiteaApp.php @@ -107,6 +107,18 @@ class GiteaApp extends BaseModel return ! empty($this->access_token) && ! empty($this->refresh_token); } + /** + * The organization field limits the source to repositories of these owners. Empty allows all owners. + */ + public function allowsRepositoryOwner(?string $owner): bool + { + $organizations = collect(explode(',', (string) $this->organization)) + ->map(fn ($organization) => strtolower(trim($organization))) + ->filter(); + + return $organizations->isEmpty() || $organizations->contains(strtolower((string) $owner)); + } + public function apiUrlBase(): string { $apiUrl = rtrim($this->api_url, '/'); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index f66b69f8a3..e6c939832e 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -507,6 +507,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request) $request->offsetUnset('domains'); $request->offsetUnset('instant_deploy'); $request->offsetUnset('github_app_uuid'); + $request->offsetUnset('gitea_app_uuid'); $request->offsetUnset('private_key_uuid'); $request->offsetUnset('use_build_server'); $request->offsetUnset('use_build_secrets'); diff --git a/bootstrap/helpers/gitea.php b/bootstrap/helpers/gitea.php index b1911c481b..439b0e012d 100644 --- a/bootstrap/helpers/gitea.php +++ b/bootstrap/helpers/gitea.php @@ -119,14 +119,9 @@ function loadGiteaRepositories(GiteaApp $source, int $page = 1): array $repositories = collect($response->json()); $hasMore = count($response->json()) === GITEA_PAGE_SIZE; - $organizations = collect(explode(',', (string) $source->organization)) - ->map(fn ($organization) => strtolower(trim($organization))) - ->filter(); - if ($organizations->isNotEmpty()) { - $repositories = $repositories->filter( - fn ($repository) => $organizations->contains(strtolower((string) data_get($repository, 'owner.login'))) - ); - } + $repositories = $repositories->filter( + fn ($repository) => $source->allowsRepositoryOwner(data_get($repository, 'owner.login')) + ); return [ 'total_count' => $repositories->count(), diff --git a/openapi.json b/openapi.json index 72dd1dbd79..5fc2dc5832 100644 --- a/openapi.json +++ b/openapi.json @@ -1790,6 +1790,670 @@ ] } }, + "\/applications\/private-gitea-app": { + "post": { + "tags": [ + "Applications" + ], + "summary": "Create (Private - Gitea App)", + "description": "Create new application based on a private repository through a Gitea App.", + "operationId": "create-private-gitea-app-application", + "requestBody": { + "description": "Application object that needs to be created.", + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "project_uuid", + "server_uuid", + "gitea_app_uuid", + "git_repository", + "git_branch", + "build_pack" + ], + "properties": { + "project_uuid": { + "type": "string", + "description": "The project UUID." + }, + "server_uuid": { + "type": "string", + "description": "The server UUID." + }, + "environment_name": { + "type": "string", + "description": "The environment name. You need to provide at least one of environment_name or environment_uuid." + }, + "environment_uuid": { + "type": "string", + "description": "The environment UUID. You need to provide at least one of environment_name or environment_uuid." + }, + "gitea_app_uuid": { + "type": "string", + "description": "The Gitea App UUID. The source must be connected." + }, + "git_repository": { + "type": "string", + "description": "The repository as owner\/name, or its URL on the Gitea instance." + }, + "git_branch": { + "type": "string", + "description": "The git branch." + }, + "ports_exposes": { + "type": "string", + "description": "The ports to expose." + }, + "destination_uuid": { + "type": "string", + "description": "The destination UUID." + }, + "build_pack": { + "type": "string", + "enum": [ + "nixpacks", + "railpack", + "static", + "dockerfile", + "dockercompose" + ], + "description": "The build pack type." + }, + "name": { + "type": "string", + "description": "The application name." + }, + "description": { + "type": "string", + "description": "The application description." + }, + "domains": { + "type": "string", + "description": "The application URLs in a comma-separated list." + }, + "noindex_domains": { + "type": "array", + "items": { + "type": "string" + }, + "description": "The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored." + }, + "git_commit_sha": { + "type": "string", + "description": "The git commit SHA." + }, + "docker_registry_image_name": { + "type": "string", + "description": "The docker registry image name." + }, + "docker_registry_image_tag": { + "type": "string", + "description": "The docker registry image tag." + }, + "is_static": { + "type": "boolean", + "description": "The flag to indicate if the application is static." + }, + "is_spa": { + "type": "boolean", + "description": "The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true." + }, + "is_auto_deploy_enabled": { + "type": "boolean", + "description": "The flag to indicate if auto-deploy is enabled on git push. Defaults to true." + }, + "is_force_https_enabled": { + "type": "boolean", + "description": "The flag to indicate if HTTPS is forced. Defaults to true." + }, + "is_preview_deployments_enabled": { + "type": "boolean", + "description": "Enable preview deployments for pull requests." + }, + "static_image": { + "type": "string", + "enum": [ + "nginx:alpine" + ], + "description": "The static image." + }, + "install_command": { + "type": "string", + "description": "The install command." + }, + "build_command": { + "type": "string", + "description": "The build command." + }, + "start_command": { + "type": "string", + "description": "The start command." + }, + "ports_mappings": { + "type": "string", + "description": "The ports mappings." + }, + "base_directory": { + "type": "string", + "description": "The base directory for all commands." + }, + "publish_directory": { + "type": "string", + "description": "The publish directory." + }, + "health_check_enabled": { + "type": "boolean", + "description": "Health check enabled." + }, + "health_check_path": { + "type": "string", + "description": "Health check path." + }, + "health_check_port": { + "type": "string", + "nullable": true, + "description": "Health check port." + }, + "health_check_host": { + "type": "string", + "nullable": true, + "description": "Health check host." + }, + "health_check_method": { + "type": "string", + "description": "Health check method." + }, + "health_check_return_code": { + "type": "integer", + "description": "Health check return code." + }, + "health_check_scheme": { + "type": "string", + "description": "Health check scheme." + }, + "health_check_response_text": { + "type": "string", + "nullable": true, + "description": "Health check response text." + }, + "health_check_interval": { + "type": "integer", + "description": "Health check interval in seconds." + }, + "health_check_timeout": { + "type": "integer", + "description": "Health check timeout in seconds." + }, + "health_check_retries": { + "type": "integer", + "description": "Health check retries count." + }, + "health_check_start_period": { + "type": "integer", + "description": "Health check start period in seconds." + }, + "limits_memory": { + "type": "string", + "description": "Memory limit." + }, + "limits_memory_swap": { + "type": "string", + "description": "Memory swap limit." + }, + "limits_memory_swappiness": { + "type": "integer", + "description": "Memory swappiness." + }, + "limits_memory_reservation": { + "type": "string", + "description": "Memory reservation." + }, + "limits_cpus": { + "type": "string", + "description": "CPU limit." + }, + "limits_cpuset": { + "type": "string", + "nullable": true, + "description": "CPU set." + }, + "limits_cpu_shares": { + "type": "integer", + "description": "CPU shares." + }, + "custom_labels": { + "type": "string", + "description": "Custom labels." + }, + "custom_docker_run_options": { + "type": "string", + "description": "Custom docker run options." + }, + "post_deployment_command": { + "type": "string", + "description": "Post deployment command." + }, + "post_deployment_command_container": { + "type": "string", + "description": "Post deployment command container." + }, + "pre_deployment_command": { + "type": "string", + "description": "Pre deployment command." + }, + "pre_deployment_command_container": { + "type": "string", + "description": "Pre deployment command container." + }, + "manual_webhook_secret_github": { + "type": "string", + "description": "Manual webhook secret for Github. Needs the deploy ability." + }, + "manual_webhook_secret_gitlab": { + "type": "string", + "description": "Manual webhook secret for Gitlab. Needs the deploy ability." + }, + "manual_webhook_secret_bitbucket": { + "type": "string", + "description": "Manual webhook secret for Bitbucket. Needs the deploy ability." + }, + "manual_webhook_secret_gitea": { + "type": "string", + "description": "Manual webhook secret for Gitea. Needs the deploy ability." + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "How to set redirect with Traefik \/ Caddy. www<->non-www.", + "enum": [ + "www", + "non-www", + "both" + ] + }, + "instant_deploy": { + "type": "boolean", + "description": "The flag to indicate if the application should be deployed instantly." + }, + "dockerfile": { + "type": "string", + "description": "The Dockerfile content." + }, + "dockerfile_location": { + "type": "string", + "description": "The Dockerfile location in the repository" + }, + "dockerfile_target_build": { + "type": "string", + "nullable": true, + "description": "The Docker build target (stage) in the Dockerfile." + }, + "docker_compose_location": { + "type": "string", + "description": "The Docker Compose location." + }, + "docker_compose_custom_start_command": { + "type": "string", + "description": "The Docker Compose custom start command." + }, + "docker_compose_custom_build_command": { + "type": "string", + "description": "The Docker Compose custom build command." + }, + "docker_compose_domains": { + "type": "array", + "description": "Array of URLs to be applied to containers of a dockercompose application.", + "items": { + "properties": { + "name": { + "type": "string", + "description": "The service name as defined in docker-compose." + }, + "domain": { + "type": "string", + "description": "Comma-separated list of URLs (e.g. \"https:\/\/app.coolify.io,https:\/\/app2.coolify.io\")" + }, + "redirect": { + "type": "string", + "nullable": true, + "description": "Per-service www\/non-www redirect for this compose service.", + "enum": [ + "www", + "non-www", + "both" + ] + } + }, + "type": "object" + } + }, + "watch_paths": { + "type": "string", + "description": "Watch path patterns, one per line (newline-separated). Commas do not separate patterns." + }, + "use_build_server": { + "type": "boolean", + "nullable": true, + "description": "Use build server." + }, + "use_build_secrets": { + "type": "boolean", + "default": false, + "description": "Use Docker Build Secrets for build-time environment variables." + }, + "is_git_submodules_enabled": { + "type": "boolean", + "description": "Clone Git submodules." + }, + "is_git_lfs_enabled": { + "type": "boolean", + "description": "Enable Git LFS." + }, + "is_git_shallow_clone_enabled": { + "type": "boolean", + "description": "Use a shallow Git clone." + }, + "is_auto_cancel_queued_deployments_enabled": { + "type": "boolean", + "description": "Cancel older queued webhook deployments when a newer push arrives. Default: true." + }, + "is_auto_cancel_building_deployments_enabled": { + "type": "boolean", + "description": "Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false." + }, + "disable_build_cache": { + "type": "boolean", + "description": "Disable the build cache." + }, + "inject_build_args_to_dockerfile": { + "type": "boolean", + "description": "Inject build arguments into the Dockerfile build." + }, + "include_source_commit_in_build": { + "type": "boolean", + "description": "Include the source commit in the build." + }, + "is_env_sorting_enabled": { + "type": "boolean", + "description": "Sort environment variables." + }, + "redact_all_env_values_in_logs": { + "type": "boolean", + "description": "Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions." + }, + "include_coolify_default_variables": { + "type": "boolean", + "default": true, + "description": "Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it." + }, + "is_pr_deployments_public_enabled": { + "type": "boolean", + "description": "Make pull request deployments public." + }, + "stop_grace_period": { + "type": "integer", + "nullable": true, + "minimum": 1, + "maximum": 3600, + "description": "Container stop grace period in seconds." + }, + "rolling_update_drain_seconds": { + "type": "integer", + "minimum": 0, + "maximum": 300, + "description": "Seconds to wait after the new container is healthy before old containers stop during a rolling update. 0 turns the wait off. Default: 5." + }, + "docker_images_to_keep": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "Number of Docker images to retain." + }, + "is_gzip_enabled": { + "type": "boolean", + "description": "Enable gzip compression." + }, + "is_stripprefix_enabled": { + "type": "boolean", + "description": "Enable path prefix stripping." + }, + "is_permanent_redirect_enabled": { + "type": "boolean", + "description": "Use permanent redirects (301\/308) instead of temporary (302\/307) for the HTTP to HTTPS and www redirects. Defaults to false." + }, + "traefik_certificate_resolver": { + "type": "string", + "nullable": true, + "description": "Traefik certificate resolver for this application. Null uses the server setting, \"none\" uses no resolver." + }, + "is_container_label_readonly_enabled": { + "type": "boolean", + "default": true, + "description": "Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels." + }, + "is_raw_compose_deployment_enabled": { + "type": "boolean", + "description": "Deploy the raw Docker Compose definition." + }, + "is_log_drain_enabled": { + "type": "boolean", + "description": "Enable log drain for the application." + }, + "is_gpu_enabled": { + "type": "boolean", + "description": "Enable GPU support." + }, + "gpu_driver": { + "type": "string", + "nullable": true, + "description": "GPU driver name." + }, + "gpu_count": { + "type": "string", + "nullable": true, + "description": "Number of GPUs to allocate." + }, + "gpu_device_ids": { + "type": "string", + "nullable": true, + "description": "Comma-separated GPU device IDs." + }, + "gpu_options": { + "type": "string", + "nullable": true, + "description": "Additional GPU options." + }, + "is_consistent_container_name_enabled": { + "type": "boolean", + "description": "Use a consistent container name across deployments." + }, + "custom_internal_name": { + "type": "string", + "nullable": true, + "description": "Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422." + }, + "custom_container_name_prefix": { + "type": "string", + "nullable": true, + "description": "Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance." + }, + "preview_url_template": { + "type": "string", + "description": "Preview URL template." + }, + "max_restart_count": { + "type": "integer", + "minimum": 0, + "description": "Maximum container restart count before stopping." + }, + "is_http_basic_auth_enabled": { + "type": "boolean", + "description": "HTTP Basic Authentication enabled." + }, + "http_basic_auth_username": { + "type": "string", + "nullable": true, + "description": "Username for HTTP Basic Authentication" + }, + "http_basic_auth_password": { + "type": "string", + "nullable": true, + "description": "Password for HTTP Basic Authentication" + }, + "http_basic_auth_hash_algorithm": { + "type": "string", + "enum": [ + "bcrypt", + "argon2id" + ], + "default": "bcrypt", + "description": "Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer." + }, + "http_basic_auth_bcrypt_cost": { + "type": "integer", + "minimum": 4, + "maximum": 14, + "default": 10, + "description": "Bcrypt cost for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_memory_cost": { + "type": "integer", + "minimum": 8192, + "maximum": 262144, + "default": 65536, + "description": "Argon2id memory cost in KiB for the HTTP Basic Authentication password." + }, + "http_basic_auth_argon2id_time_cost": { + "type": "integer", + "minimum": 1, + "maximum": 12, + "default": 4, + "description": "Argon2id time cost (iterations) for the HTTP Basic Authentication password." + }, + "connect_to_docker_network": { + "type": "boolean", + "description": "The flag to connect the service to the predefined Docker network." + }, + "force_domain_override": { + "type": "boolean", + "description": "Force domain usage even if conflicts are detected. Default is false." + }, + "autogenerate_domain": { + "type": "boolean", + "default": true, + "description": "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." + }, + "is_container_label_escape_enabled": { + "type": "boolean", + "default": true, + "description": "Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off." + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Tags to assign to the application." + }, + "is_preserve_repository_enabled": { + "type": "boolean", + "default": false, + "description": "Preserve repository during deployment." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Application created successfully.", + "content": { + "application\/json": { + "schema": { + "properties": { + "uuid": { + "type": "string" + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "403": { + "description": "The token needs the deploy ability to set a manual webhook secret." + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "409": { + "description": "Domain conflicts detected.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Domain conflicts detected. Use force_domain_override=true to proceed." + }, + "warning": { + "type": "string", + "example": "Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior." + }, + "conflicts": { + "type": "array", + "items": { + "properties": { + "domain": { + "type": "string", + "example": "example.com" + }, + "resource_name": { + "type": "string", + "example": "My Application" + }, + "resource_uuid": { + "type": "string", + "nullable": true, + "example": "abc123-def456" + }, + "resource_type": { + "type": "string", + "enum": [ + "application", + "service", + "instance" + ], + "example": "application" + }, + "message": { + "type": "string", + "example": "Domain example.com is already in use by application 'My Application'" + } + }, + "type": "object" + } + } + }, + "type": "object" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/applications\/private-deploy-key": { "post": { "tags": [ @@ -3706,6 +4370,10 @@ "type": "string", "description": "The Github App UUID." }, + "gitea_app_uuid": { + "type": "string", + "description": "The Gitea App UUID. The source must be connected. Do not send it together with github_app_uuid." + }, "git_repository": { "type": "string", "description": "The git repository URL." @@ -12191,6 +12859,90 @@ ] } }, + "\/gitea-apps": { + "get": { + "tags": [ + "Gitea Apps" + ], + "summary": "List", + "description": "List all Gitea apps for the current team (and system-wide sources).", + "operationId": "list-gitea-apps", + "responses": { + "200": { + "description": "List of Gitea apps.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "properties": { + "id": { + "type": "integer" + }, + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "organization": { + "type": "string", + "nullable": true + }, + "api_url": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "custom_user": { + "type": "string" + }, + "custom_port": { + "type": "integer" + }, + "client_id": { + "type": "string", + "nullable": true + }, + "redirect_uri": { + "type": "string", + "nullable": true + }, + "is_system_wide": { + "type": "boolean" + }, + "is_public": { + "type": "boolean" + }, + "is_connected": { + "type": "boolean", + "description": "True when the OAuth connection is complete and the source can deploy private repositories." + }, + "team_id": { + "type": "integer" + } + }, + "type": "object" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/github-apps": { "get": { "tags": [ @@ -28431,6 +29183,10 @@ "name": "DNS", "description": "DNS" }, + { + "name": "Gitea Apps", + "description": "Gitea Apps" + }, { "name": "GitHub Apps", "description": "GitHub Apps" diff --git a/openapi.yaml b/openapi.yaml index 770cb82f08..fa32cdb0e0 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1164,6 +1164,448 @@ paths: security: - bearerAuth: [] + /applications/private-gitea-app: + post: + tags: + - Applications + summary: 'Create (Private - Gitea App)' + description: 'Create new application based on a private repository through a Gitea App.' + operationId: create-private-gitea-app-application + requestBody: + description: 'Application object that needs to be created.' + required: true + content: + application/json: + schema: + required: + - project_uuid + - server_uuid + - gitea_app_uuid + - git_repository + - git_branch + - build_pack + properties: + project_uuid: + type: string + description: 'The project UUID.' + server_uuid: + type: string + description: 'The server UUID.' + environment_name: + type: string + description: 'The environment name. You need to provide at least one of environment_name or environment_uuid.' + environment_uuid: + type: string + description: 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.' + gitea_app_uuid: + type: string + description: 'The Gitea App UUID. The source must be connected.' + git_repository: + type: string + description: 'The repository as owner/name, or its URL on the Gitea instance.' + git_branch: + type: string + description: 'The git branch.' + ports_exposes: + type: string + description: 'The ports to expose.' + destination_uuid: + type: string + description: 'The destination UUID.' + build_pack: + type: string + enum: [nixpacks, railpack, static, dockerfile, dockercompose] + description: 'The build pack type.' + name: + type: string + description: 'The application name.' + description: + type: string + description: 'The application description.' + domains: + type: string + description: 'The application URLs in a comma-separated list.' + noindex_domains: + type: array + items: { type: string } + description: 'The subset of the application domains served with an X-Robots-Tag: noindex, nofollow response header, keeping them out of search engines. Entries that are not among the application domains are ignored.' + git_commit_sha: + type: string + description: 'The git commit SHA.' + docker_registry_image_name: + type: string + description: 'The docker registry image name.' + docker_registry_image_tag: + type: string + description: 'The docker registry image tag.' + is_static: + type: boolean + description: 'The flag to indicate if the application is static.' + is_spa: + type: boolean + description: 'The flag to indicate if the application is a single-page application (SPA). Only relevant when is_static is true.' + is_auto_deploy_enabled: + type: boolean + description: 'The flag to indicate if auto-deploy is enabled on git push. Defaults to true.' + is_force_https_enabled: + type: boolean + description: 'The flag to indicate if HTTPS is forced. Defaults to true.' + is_preview_deployments_enabled: + type: boolean + description: 'Enable preview deployments for pull requests.' + static_image: + type: string + enum: ['nginx:alpine'] + description: 'The static image.' + install_command: + type: string + description: 'The install command.' + build_command: + type: string + description: 'The build command.' + start_command: + type: string + description: 'The start command.' + ports_mappings: + type: string + description: 'The ports mappings.' + base_directory: + type: string + description: 'The base directory for all commands.' + publish_directory: + type: string + description: 'The publish directory.' + health_check_enabled: + type: boolean + description: 'Health check enabled.' + health_check_path: + type: string + description: 'Health check path.' + health_check_port: + type: string + nullable: true + description: 'Health check port.' + health_check_host: + type: string + nullable: true + description: 'Health check host.' + health_check_method: + type: string + description: 'Health check method.' + health_check_return_code: + type: integer + description: 'Health check return code.' + health_check_scheme: + type: string + description: 'Health check scheme.' + health_check_response_text: + type: string + nullable: true + description: 'Health check response text.' + health_check_interval: + type: integer + description: 'Health check interval in seconds.' + health_check_timeout: + type: integer + description: 'Health check timeout in seconds.' + health_check_retries: + type: integer + description: 'Health check retries count.' + health_check_start_period: + type: integer + description: 'Health check start period in seconds.' + limits_memory: + type: string + description: 'Memory limit.' + limits_memory_swap: + type: string + description: 'Memory swap limit.' + limits_memory_swappiness: + type: integer + description: 'Memory swappiness.' + limits_memory_reservation: + type: string + description: 'Memory reservation.' + limits_cpus: + type: string + description: 'CPU limit.' + limits_cpuset: + type: string + nullable: true + description: 'CPU set.' + limits_cpu_shares: + type: integer + description: 'CPU shares.' + custom_labels: + type: string + description: 'Custom labels.' + custom_docker_run_options: + type: string + description: 'Custom docker run options.' + post_deployment_command: + type: string + description: 'Post deployment command.' + post_deployment_command_container: + type: string + description: 'Post deployment command container.' + pre_deployment_command: + type: string + description: 'Pre deployment command.' + pre_deployment_command_container: + type: string + description: 'Pre deployment command container.' + manual_webhook_secret_github: + type: string + description: 'Manual webhook secret for Github. Needs the deploy ability.' + manual_webhook_secret_gitlab: + type: string + description: 'Manual webhook secret for Gitlab. Needs the deploy ability.' + manual_webhook_secret_bitbucket: + type: string + description: 'Manual webhook secret for Bitbucket. Needs the deploy ability.' + manual_webhook_secret_gitea: + type: string + description: 'Manual webhook secret for Gitea. Needs the deploy ability.' + redirect: + type: string + nullable: true + description: 'How to set redirect with Traefik / Caddy. www<->non-www.' + enum: [www, non-www, both] + instant_deploy: + type: boolean + description: 'The flag to indicate if the application should be deployed instantly.' + dockerfile: + type: string + description: 'The Dockerfile content.' + dockerfile_location: + type: string + description: 'The Dockerfile location in the repository' + dockerfile_target_build: + type: string + nullable: true + description: 'The Docker build target (stage) in the Dockerfile.' + docker_compose_location: + type: string + description: 'The Docker Compose location.' + docker_compose_custom_start_command: + type: string + description: 'The Docker Compose custom start command.' + docker_compose_custom_build_command: + type: string + description: 'The Docker Compose custom build command.' + docker_compose_domains: + type: array + description: 'Array of URLs to be applied to containers of a dockercompose application.' + items: { properties: { name: { type: string, description: 'The service name as defined in docker-compose.' }, domain: { type: string, description: 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")' }, redirect: { type: string, nullable: true, description: 'Per-service www/non-www redirect for this compose service.', enum: [www, non-www, both] } }, type: object } + watch_paths: + type: string + description: 'Watch path patterns, one per line (newline-separated). Commas do not separate patterns.' + use_build_server: + type: boolean + nullable: true + description: 'Use build server.' + use_build_secrets: + type: boolean + default: false + description: 'Use Docker Build Secrets for build-time environment variables.' + is_git_submodules_enabled: + type: boolean + description: 'Clone Git submodules.' + is_git_lfs_enabled: + type: boolean + description: 'Enable Git LFS.' + is_git_shallow_clone_enabled: + type: boolean + description: 'Use a shallow Git clone.' + is_auto_cancel_queued_deployments_enabled: + type: boolean + description: 'Cancel older queued webhook deployments when a newer push arrives. Default: true.' + is_auto_cancel_building_deployments_enabled: + type: boolean + description: 'Also stop an older webhook deployment that is still building when a newer push arrives. A deployment that already started to replace containers always finishes. This also cancels queued webhook deployments. Default: false.' + disable_build_cache: + type: boolean + description: 'Disable the build cache.' + inject_build_args_to_dockerfile: + type: boolean + description: 'Inject build arguments into the Dockerfile build.' + include_source_commit_in_build: + type: boolean + description: 'Include the source commit in the build.' + is_env_sorting_enabled: + type: boolean + description: 'Sort environment variables.' + redact_all_env_values_in_logs: + type: boolean + description: 'Redact all environment variable values in logs. When false, only locked (shown once) values are redacted. Requires team admin permissions.' + include_coolify_default_variables: + type: boolean + default: true + description: 'Add the predefined COOLIFY_* variables (COOLIFY_URL, COOLIFY_FQDN, ...) to the environment. Requires team admin permissions. Redeploy to apply it.' + is_pr_deployments_public_enabled: + type: boolean + description: 'Make pull request deployments public.' + stop_grace_period: + type: integer + nullable: true + minimum: 1 + maximum: 3600 + description: 'Container stop grace period in seconds.' + rolling_update_drain_seconds: + type: integer + minimum: 0 + maximum: 300 + description: 'Seconds to wait after the new container is healthy before old containers stop during a rolling update. 0 turns the wait off. Default: 5.' + docker_images_to_keep: + type: integer + minimum: 0 + maximum: 100 + description: 'Number of Docker images to retain.' + is_gzip_enabled: + type: boolean + description: 'Enable gzip compression.' + is_stripprefix_enabled: + type: boolean + description: 'Enable path prefix stripping.' + is_permanent_redirect_enabled: + type: boolean + description: 'Use permanent redirects (301/308) instead of temporary (302/307) for the HTTP to HTTPS and www redirects. Defaults to false.' + traefik_certificate_resolver: + type: string + nullable: true + description: 'Traefik certificate resolver for this application. Null uses the server setting, "none" uses no resolver.' + is_container_label_readonly_enabled: + type: boolean + default: true + description: 'Let Coolify manage the container labels. True regenerates custom_labels from domains, redirect, and proxy settings. False keeps custom_labels as you set them: domains, redirect, and basic auth changes then do not change the labels.' + is_raw_compose_deployment_enabled: + type: boolean + description: 'Deploy the raw Docker Compose definition.' + is_log_drain_enabled: + type: boolean + description: 'Enable log drain for the application.' + is_gpu_enabled: + type: boolean + description: 'Enable GPU support.' + gpu_driver: + type: string + nullable: true + description: 'GPU driver name.' + gpu_count: + type: string + nullable: true + description: 'Number of GPUs to allocate.' + gpu_device_ids: + type: string + nullable: true + description: 'Comma-separated GPU device IDs.' + gpu_options: + type: string + nullable: true + description: 'Additional GPU options.' + is_consistent_container_name_enabled: + type: boolean + description: 'Use a consistent container name across deployments.' + custom_internal_name: + type: string + nullable: true + description: 'Custom internal container name. Turns is_consistent_container_name_enabled on when that field is not sent; sending it as false together with a name returns 422.' + custom_container_name_prefix: + type: string + nullable: true + description: 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.' + preview_url_template: + type: string + description: 'Preview URL template.' + max_restart_count: + type: integer + minimum: 0 + description: 'Maximum container restart count before stopping.' + is_http_basic_auth_enabled: + type: boolean + description: 'HTTP Basic Authentication enabled.' + http_basic_auth_username: + type: string + nullable: true + description: 'Username for HTTP Basic Authentication' + http_basic_auth_password: + type: string + nullable: true + description: 'Password for HTTP Basic Authentication' + http_basic_auth_hash_algorithm: + type: string + enum: [bcrypt, argon2id] + default: bcrypt + description: 'Hash algorithm for the HTTP Basic Authentication password. Argon2id needs the Caddy proxy, version 2.11 or newer.' + http_basic_auth_bcrypt_cost: + type: integer + minimum: 4 + maximum: 14 + default: 10 + description: 'Bcrypt cost for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_memory_cost: + type: integer + minimum: 8192 + maximum: 262144 + default: 65536 + description: 'Argon2id memory cost in KiB for the HTTP Basic Authentication password.' + http_basic_auth_argon2id_time_cost: + type: integer + minimum: 1 + maximum: 12 + default: 4 + description: 'Argon2id time cost (iterations) for the HTTP Basic Authentication password.' + connect_to_docker_network: + type: boolean + description: 'The flag to connect the service to the predefined Docker network.' + force_domain_override: + type: boolean + description: 'Force domain usage even if conflicts are detected. Default is false.' + autogenerate_domain: + type: boolean + default: true + description: "If true and domains is empty, auto-generate a domain using the server's wildcard domain or sslip.io fallback. Ignored for the dockercompose build pack, which uses docker_compose_domains. Default: true." + is_container_label_escape_enabled: + type: boolean + default: true + description: 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.' + tags: + type: array + items: { type: string } + description: 'Tags to assign to the application.' + is_preserve_repository_enabled: + type: boolean + default: false + description: 'Preserve repository during deployment.' + type: object + responses: + '201': + description: 'Application created successfully.' + content: + application/json: + schema: + properties: + uuid: { type: string } + type: object + '401': + $ref: '#/components/responses/401' + '403': + description: 'The token needs the deploy ability to set a manual webhook secret.' + '400': + $ref: '#/components/responses/400' + '409': + description: 'Domain conflicts detected.' + content: + application/json: + schema: + properties: + message: { type: string, example: 'Domain conflicts detected. Use force_domain_override=true to proceed.' } + warning: { type: string, example: 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.' } + conflicts: { type: array, items: { properties: { domain: { type: string, example: example.com }, resource_name: { type: string, example: 'My Application' }, resource_uuid: { type: string, nullable: true, example: abc123-def456 }, resource_type: { type: string, enum: [application, service, instance], example: application }, message: { type: string, example: "Domain example.com is already in use by application 'My Application'" } }, type: object } } + type: object + security: + - + bearerAuth: [] /applications/private-deploy-key: post: tags: @@ -2467,6 +2909,9 @@ paths: github_app_uuid: type: string description: 'The Github App UUID.' + gitea_app_uuid: + type: string + description: 'The Gitea App UUID. The source must be connected. Do not send it together with github_app_uuid.' git_repository: type: string description: 'The git repository URL.' @@ -7949,6 +8394,30 @@ paths: security: - bearerAuth: [] + /gitea-apps: + get: + tags: + - 'Gitea Apps' + summary: List + description: 'List all Gitea apps for the current team (and system-wide sources).' + operationId: list-gitea-apps + responses: + '200': + description: 'List of Gitea apps.' + content: + application/json: + schema: + type: array + items: + properties: { id: { type: integer }, uuid: { type: string }, name: { type: string }, organization: { type: string, nullable: true }, api_url: { type: string }, html_url: { type: string }, custom_user: { type: string }, custom_port: { type: integer }, client_id: { type: string, nullable: true }, redirect_uri: { type: string, nullable: true }, is_system_wide: { type: boolean }, is_public: { type: boolean }, is_connected: { type: boolean, description: 'True when the OAuth connection is complete and the source can deploy private repositories.' }, team_id: { type: integer } } + type: object + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + security: + - + bearerAuth: [] /github-apps: get: tags: @@ -18235,6 +18704,9 @@ tags: - name: DNS description: DNS + - + name: 'Gitea Apps' + description: 'Gitea Apps' - name: 'GitHub Apps' description: 'GitHub Apps' diff --git a/routes/api.php b/routes/api.php index 476ef2ba45..2f883c0cd3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\DeployController; use App\Http\Controllers\Api\DestinationsController; use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\DnsController; +use App\Http\Controllers\Api\GiteaController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; @@ -255,6 +256,7 @@ Route::group([ Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']); Route::post('/applications/public', [ApplicationsController::class, 'create_public_application'])->middleware(['api.ability:write']); Route::post('/applications/private-github-app', [ApplicationsController::class, 'create_private_gh_app_application'])->middleware(['api.ability:write']); + Route::post('/applications/private-gitea-app', [ApplicationsController::class, 'create_private_gitea_app_application'])->middleware(['api.ability:write']); Route::post('/applications/private-deploy-key', [ApplicationsController::class, 'create_private_deploy_key_application'])->middleware(['api.ability:write']); Route::post('/applications/dockerfile', [ApplicationsController::class, 'create_dockerfile_application'])->middleware(['api.ability:write']); Route::post('/applications/dockerimage', [ApplicationsController::class, 'create_dockerimage_application'])->middleware(['api.ability:write']); @@ -335,6 +337,8 @@ Route::group([ Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']); Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:delete']); + Route::get('/gitea-apps', [GiteaController::class, 'list_gitea_apps'])->middleware(['api.ability:read']); + Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']); Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']); Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/GiteaAppsApiTest.php b/tests/Feature/Api/GiteaAppsApiTest.php new file mode 100644 index 0000000000..528d98af86 --- /dev/null +++ b/tests/Feature/Api/GiteaAppsApiTest.php @@ -0,0 +1,235 @@ + 'Team Gitea', + 'api_url' => GITEA_API_TEST_URL.'/api/v1', + 'html_url' => GITEA_API_TEST_URL, + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'access_token' => 'gitea-access-token', + 'refresh_token' => 'gitea-refresh-token', + 'webhook_secret' => 'gitea-webhook-secret', + 'expires_at' => time() + 3600, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ...$attributes, + ]); +} + +function fakeGiteaRepository(string $path = 'acme/app', string $owner = 'acme', int $id = 4242): void +{ + Http::fake([ + GITEA_API_TEST_URL."/api/v1/repos/{$path}" => Http::response(['id' => $id, 'full_name' => $path, 'owner' => ['login' => $owner]]), + '*' => Http::response(['message' => 'Not Found'], 404), + ]); +} + +beforeEach(function () { + Queue::fake(); + Server::flushIdentityMap(); + 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']); + session(['currentTeam' => $this->team]); + $this->headers = ['Authorization' => 'Bearer '.$this->user->createToken('gitea-api-test', ['*'])->plainTextToken]; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $this->project->id]); + StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + + $this->giteaApp = giteaApiTestSource($this->team); +}); + +function createGiteaApplicationPayload(object $test, array $overrides = []): array +{ + return [ + 'project_uuid' => $test->project->uuid, + 'environment_uuid' => $test->environment->uuid, + 'server_uuid' => $test->server->uuid, + 'gitea_app_uuid' => $test->giteaApp->uuid, + 'git_repository' => 'acme/app', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'autogenerate_domain' => false, + ...$overrides, + ]; +} + +describe('GET /api/v1/gitea-apps', function () { + test('lists team and system-wide sources without secrets for read tokens', function () { + $otherTeam = Team::factory()->create(); + giteaApiTestSource($otherTeam, ['name' => 'Foreign System Gitea', 'is_system_wide' => true]); + giteaApiTestSource($otherTeam, ['name' => 'Foreign Private Gitea']); + giteaApiTestSource($this->team, ['name' => 'Unconnected Gitea', 'access_token' => null, 'refresh_token' => null]); + $readToken = $this->user->createToken('read-token', ['read'])->plainTextToken; + + $response = $this->withHeaders(['Authorization' => 'Bearer '.$readToken]) + ->getJson('/api/v1/gitea-apps') + ->assertOk() + ->assertJsonCount(3); + + $apps = collect($response->json())->keyBy('name'); + expect($apps->keys()->sort()->values()->all())->toBe(['Foreign System Gitea', 'Team Gitea', 'Unconnected Gitea']) + ->and($apps['Team Gitea']['is_connected'])->toBeTrue() + ->and($apps['Unconnected Gitea']['is_connected'])->toBeFalse(); + foreach ($apps as $app) { + expect($app)->not->toHaveKeys(['client_secret', 'access_token', 'refresh_token', 'webhook_secret']); + } + }); + + test('shows secrets of own sources to root tokens only', function () { + $otherTeam = Team::factory()->create(); + giteaApiTestSource($otherTeam, ['name' => 'Foreign System Gitea', 'is_system_wide' => true]); + + $apps = collect($this->withHeaders($this->headers)->getJson('/api/v1/gitea-apps')->assertOk()->json())->keyBy('name'); + + expect($apps['Team Gitea']['client_secret'])->toBe('client-secret') + ->and($apps['Foreign System Gitea'])->not->toHaveKey('client_secret'); + }); +}); + +describe('POST /api/v1/applications/private-gitea-app', function () { + test('creates an application from a repository URL on the Gitea instance', function () { + fakeGiteaRepository(); + + $response = $this->withHeaders($this->headers) + ->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, [ + 'git_repository' => GITEA_API_TEST_URL.'/acme/app.git', + ])) + ->assertCreated(); + + $application = Application::where('uuid', $response->json('uuid'))->firstOrFail(); + expect($application->source_type)->toBe(GiteaApp::class) + ->and($application->source_id)->toBe($this->giteaApp->id) + ->and($application->git_repository)->toBe('acme/app') + ->and($application->repository_project_id)->toBe(4242); + Http::assertSent(fn ($request) => $request->url() === GITEA_API_TEST_URL.'/api/v1/repos/acme/app' + && $request->hasHeader('Authorization', 'Bearer gitea-access-token')); + }); + + test('returns 404 for a Gitea App of another team or one that is not connected', function (string $case) { + $source = $case === 'other team' + ? giteaApiTestSource(Team::factory()->create()) + : giteaApiTestSource($this->team, ['access_token' => null, 'refresh_token' => null]); + fakeGiteaRepository(); + + $this->withHeaders($this->headers) + ->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['gitea_app_uuid' => $source->uuid])) + ->assertNotFound() + ->assertJson(['message' => 'Gitea App not found.']); + + expect(Application::count())->toBe(0); + Http::assertNothingSent(); + })->with(['other team', 'not connected']); + + test('rejects a repository of an owner outside the source organizations', function () { + $this->giteaApp->update(['organization' => 'acme']); + fakeGiteaRepository('intruder/app', 'intruder'); + + $this->withHeaders($this->headers) + ->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['git_repository' => 'intruder/app'])) + ->assertNotFound() + ->assertJson(['message' => 'Repository not found or not accessible by the Gitea App.']); + + expect(Application::count())->toBe(0); + }); + + test('rejects a repository that is not on the Gitea instance of the source', function (string $repository) { + fakeGiteaRepository(); + + $this->withHeaders($this->headers) + ->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['git_repository' => $repository])) + ->assertUnprocessable() + ->assertJsonValidationErrors('git_repository'); + + Http::assertNothingSent(); + })->with([ + 'other host' => 'https://gitea.example.org/acme/app', + 'path traversal' => '../admin', + 'nested path' => 'acme/app/extra', + ]); + + test('requires gitea_app_uuid', function () { + $this->withHeaders($this->headers) + ->postJson('/api/v1/applications/private-gitea-app', createGiteaApplicationPayload($this, ['gitea_app_uuid' => null])) + ->assertUnprocessable() + ->assertJsonValidationErrors('gitea_app_uuid'); + }); +}); + +describe('PATCH /api/v1/applications/{uuid} gitea_app_uuid', function () { + beforeEach(function () { + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => StandaloneDocker::where('server_id', $this->server->id)->firstOrFail()->id, + 'destination_type' => StandaloneDocker::class, + 'git_repository' => GITEA_API_TEST_URL.'/acme/app', + 'repository_project_id' => null, + ]); + }); + + test('moves a public repository application to a Gitea App source', function () { + fakeGiteaRepository(); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['gitea_app_uuid' => $this->giteaApp->uuid]) + ->assertOk(); + + $this->application->refresh(); + expect($this->application->source_type)->toBe(GiteaApp::class) + ->and($this->application->source_id)->toBe($this->giteaApp->id) + ->and($this->application->git_repository)->toBe('acme/app') + ->and($this->application->repository_project_id)->toBe(4242); + }); + + test('does not move the application to a Gitea App of another team', function () { + $foreignSource = giteaApiTestSource(Team::factory()->create()); + $originalSource = [$this->application->source_type, $this->application->source_id]; + fakeGiteaRepository(); + + $this->withHeaders($this->headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}", ['gitea_app_uuid' => $foreignSource->uuid]) + ->assertNotFound(); + + $this->application->refresh(); + expect([$this->application->source_type, $this->application->source_id])->toBe($originalSource); + }); + + test('rejects github_app_uuid and gitea_app_uuid together', function () { + $this->withHeaders($this->headers) + ->patchJson("/api/v1/applications/{$this->application->uuid}", [ + 'gitea_app_uuid' => $this->giteaApp->uuid, + 'github_app_uuid' => 'any-github-app', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('gitea_app_uuid'); + }); +});