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)) {