feat(api): add Gitea App endpoints for sources and applications

Add GET /gitea-apps, POST /applications/private-gitea-app, and
gitea_app_uuid in PATCH /applications/{uuid}, with OpenAPI docs and MCP tool support.

Closes #12459

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-05 11:27:24 +01:00
co-authored by Claude Opus 5.5
parent 4bdf03db69
commit 3bc82eb71d
11 changed files with 1956 additions and 81 deletions
@@ -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<TSource> $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)) {
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GiteaApp;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use OpenApi\Attributes as OA;
class GiteaController extends Controller
{
private function removeSensitiveData(GiteaApp $giteaApp, int $teamId): Collection
{
$sensitiveFields = ['client_secret', 'access_token', 'refresh_token', 'webhook_secret'];
if (request()->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())
));
}
}
+3 -2
View File
@@ -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).';
}
+2 -2
View File
@@ -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'],
+12
View File
@@ -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, '/');
+1
View File
@@ -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');
+3 -8
View File
@@ -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(),
+756
View File
@@ -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"
+472
View File
@@ -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'
+4
View File
@@ -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']);
+235
View File
@@ -0,0 +1,235 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\GiteaApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
/**
* Http::GitSource() pins DNS before Http::fake() answers, so use a public IP
* literal that needs no DNS lookup. No request leaves the test.
*/
const GITEA_API_TEST_URL = 'https://1.1.1.1';
function giteaApiTestSource(Team $team, array $attributes = []): GiteaApp
{
return GiteaApp::create([
'name' => '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');
});
});