diff --git a/app/Enums/NewResourceTypes.php b/app/Enums/NewResourceTypes.php index 4fee12e131..2fccc3208b 100644 --- a/app/Enums/NewResourceTypes.php +++ b/app/Enums/NewResourceTypes.php @@ -6,6 +6,7 @@ enum NewResourceTypes: string { case PUBLIC = 'public'; case PRIVATE_GH_APP = 'private-gh-app'; + case PRIVATE_GITEA_APP = 'private-gitea-app'; case PRIVATE_DEPLOY_KEY = 'private-deploy-key'; case DOCKERFILE = 'dockerfile'; case DOCKERCOMPOSE = 'dockercompose'; diff --git a/app/Http/Controllers/Api/ApplicationPreviewsController.php b/app/Http/Controllers/Api/ApplicationPreviewsController.php index 453d605695..a2c8a90d2c 100644 --- a/app/Http/Controllers/Api/ApplicationPreviewsController.php +++ b/app/Http/Controllers/Api/ApplicationPreviewsController.php @@ -6,6 +6,7 @@ use App\Enums\BuildPackTypes; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use Illuminate\Http\JsonResponse; @@ -214,7 +215,7 @@ class ApplicationPreviewsController extends Controller if (! $isDockerImage && $gitType === null) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['git_type' => ['The git type field is required for applications without a GitHub or GitLab App source.']], + 'errors' => ['git_type' => ['The git type field is required for applications without a GitHub, GitLab, or Gitea App source.']], ], 422); } if ($isDockerImage && $dockerTag === null && blank($preview?->docker_registry_image_tag)) { @@ -303,6 +304,7 @@ class ApplicationPreviewsController extends Controller return match ($application->source?->getMorphClass()) { GithubApp::class => 'github', GitlabApp::class => 'gitlab', + GiteaApp::class => 'gitea', default => null, }; } diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index 05ceebeabd..bb34413b43 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -9,11 +9,15 @@ use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits; use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications; use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload; use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository; +use App\Livewire\Source\Gitea\Change as GiteaSource; use App\Models\Application; use App\Models\ApplicationPreview; +use App\Models\GiteaApp; use App\Services\ServerTransfer\TransferFinishChecklist; use Exception; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class Gitea extends Controller @@ -23,6 +27,340 @@ class Gitea extends Controller use ReadsWebhookPushPayload; use ValidatesPreviewDeploymentRepository; + public function redirect(Request $request) + { + try { + $code = $request->query('code'); + $state = $request->query('state'); + + if (! $code || ! $state) { + return redirect()->route('source.all')->with('error', 'Invalid Gitea OAuth callback. Missing code or state.'); + } + + // Validate the one-time, team-bound state (not a guessable source UUID) to stop forged callbacks from overwriting a source's tokens. + $payload = Cache::pull(GiteaSource::oauthStateCacheKey($state)); + $team_id = $request->user()?->currentTeam()?->id; + if (! is_array($payload) || is_null($team_id) || (int) data_get($payload, 'team_id') !== (int) $team_id) { + return redirect()->route('source.all')->with('error', 'Invalid or expired Gitea OAuth state. Please start the authorization again.'); + } + + $giteaApp = GiteaApp::whereKey(data_get($payload, 'gitea_app_id'))->firstOrFail(); + + // Only users who may administer the source can complete OAuth and store tokens. + if (! $request->user()->can('update', $giteaApp)) { + return redirect()->route('source.all')->with('error', 'You are not authorized to connect this Gitea App.'); + } + + $baseUrl = rtrim($giteaApp->html_url, '/'); + + $response = Http::GitSource($baseUrl)->asForm()->post("{$baseUrl}/login/oauth/access_token", [ + 'client_id' => $giteaApp->client_id, + 'client_secret' => $giteaApp->client_secret, + 'code' => $code, + 'grant_type' => 'authorization_code', + 'redirect_uri' => $giteaApp->redirect_uri, + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', 'Token exchange failed'); + + return redirect()->route('source.gitea.show', ['gitea_app_uuid' => $giteaApp->uuid]) + ->with('error', "Gitea OAuth failed: {$error}"); + } + + $data = $response->json(); + $giteaApp->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'], + 'expires_at' => time() + ($data['expires_in'] ?? 3600), + ]); + + return redirect()->route('source.gitea.show', ['gitea_app_uuid' => $giteaApp->uuid]); + } catch (Exception $e) { + return redirect()->route('source.all')->with('error', $e->getMessage()); + } + } + + public function normal(Request $request) + { + try { + $return_payloads = collect([]); + $x_gitea_event = Str::lower((string) $request->header('X-Gitea-Event')); + + if ($x_gitea_event === 'ping') { + return response('pong'); + } + + if (! in_array($x_gitea_event, ['push', 'pull_request'], true)) { + return response([ + 'status' => 'failed', + 'message' => 'Event not allowed. Only push and pull_request events are allowed.', + ]); + } + + $signature = (string) $request->header('X-Gitea-Signature'); + if ($signature === '') { + $signature = Str::after((string) $request->header('X-Hub-Signature-256'), 'sha256='); + } + + if ($signature === '') { + auditLogWebhookFailure('gitea', 'webhook_signature_missing', [ + 'event' => $x_gitea_event, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Missing webhook signature.', + ], 401); + } + + $gitea_app = GiteaApp::findBySignature($request->getContent(), $signature); + if (! $gitea_app) { + auditLogWebhookFailure('gitea', 'invalid_signature', [ + 'event' => $x_gitea_event, + ]); + + return response([ + 'status' => 'failed', + 'message' => 'Invalid webhook signature.', + ], 401); + } + + $payload = $request->collect(); + if (! $request->isJson()) { + $form_payload = data_get($payload, 'payload'); + $payload = is_string($form_payload) ? json_decode($form_payload, true) : null; + } + + $repository_id = $this->webhookPayloadDatabaseId($payload, 'repository.id', required: true); + $applications = Application::where('source_id', $gitea_app->id) + ->where('source_type', GiteaApp::class) + ->where('repository_project_id', $repository_id); + + if ($x_gitea_event === 'push') { + $branch = $this->webhookPushBranch(data_get($payload, 'ref')); + if (! $branch) { + return response([ + 'status' => 'failed', + 'message' => 'No branch found in the request.', + ]); + } + if ($this->isWebhookBranchDeletionPush($payload)) { + return response([ + 'status' => 'skipped', + 'message' => 'Nothing to do. Branch deleted.', + ]); + } + $commit = $this->webhookCommitSha($payload, 'after'); + + $applications = $applications->where('git_branch', $branch)->get(); + $changed_files = $this->webhookPushChangedFiles($payload); + $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (! $application->isDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Deployments disabled', + ]); + + continue; + } + + if (! $this->webhookPushMatchesWatchPaths($application, $changed_files)) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Changed files do not match watch paths.', + ]); + + continue; + } + + if ($skip_deploy_commits) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'All commits contain [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new_public_id(); + $result = queue_application_deployment( + application: $application, + deployment_uuid: $deployment_uuid, + commit: $commit ?? 'HEAD', + force_rebuild: false, + is_webhook: true, + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + auditLog('webhook.deployment.queued', [ + 'provider' => 'gitea', + 'mode' => 'app', + 'application_uuid' => $application->uuid, + 'application_name' => $application->name, + 'deployment_uuid' => $deployment_uuid, + 'commit' => $commit, + ]); + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Deployment queued.', + ]); + } + } + + if ($x_gitea_event === 'pull_request') { + $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); + $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); + if (! $branch || ! $base_branch) { + return response([ + 'status' => 'failed', + 'message' => 'No branch found in the request.', + ]); + } + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'commit' => $commit, + 'skip_deploy' => $skip_deploy_pr, + ] = $this->readPullRequestPayload($payload); + + $applications = $applications->where('git_branch', $base_branch)->get(); + + foreach ($applications as $application) { + if (! $application->destination->server->isFunctional()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]); + + continue; + } + + if (in_array($action, ['opened', 'synchronized', 'reopened'], true)) { + if (! $application->isPRDeployable()) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'Preview deployments disabled', + ]); + + continue; + } + + if (! $this->isPreviewDeploymentRepositoryTrusted( + data_get($payload, 'pull_request.head.repo.id'), + data_get($payload, 'pull_request.base.repo.id'), + data_get($payload, 'repository.id'), + $application->settings->is_pr_deployments_public_enabled, + )) { + continue; + } + + if ($skip_deploy_pr) { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'skipped', + 'message' => 'PR title contains [skip cd] or [skip ci].', + ]); + + continue; + } + + $deployment_uuid = new_public_id(); + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if (! $found) { + if ($application->build_pack === 'dockercompose') { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitea', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url ?? '', + 'docker_compose_domains' => $application->docker_compose_domains, + ]); + $pr_app->generate_preview_fqdn_compose(); + } else { + $pr_app = ApplicationPreview::create([ + 'git_type' => 'gitea', + 'application_id' => $application->id, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url ?? '', + ]); + $pr_app->generate_preview_fqdn(); + } + } + + $result = queue_application_deployment( + application: $application, + pull_request_id: $pull_request_id, + deployment_uuid: $deployment_uuid, + commit: $commit ?? 'HEAD', + force_rebuild: false, + is_webhook: true, + git_type: 'gitea', + ); + + if ($result['status'] === 'queue_full') { + return response($result['message'], 429)->header('Retry-After', 60); + } + + $return_payloads->push([ + 'application' => $application->name, + 'status' => $result['status'] ?? 'success', + 'message' => $result['message'] ?? 'Preview deployment queued.', + ]); + } elseif ($action === 'closed') { + $found = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + + if ($found) { + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'success', + 'message' => 'Preview deployment closed.', + ]); + } + } + } + } + + return response($return_payloads); + } catch (InvalidWebhookPayloadException $e) { + return response([ + 'status' => 'failed', + 'message' => $e->getMessage(), + ]); + } catch (Exception $e) { + return handleError($e); + } + } + public function manual(Request $request) { try { @@ -52,15 +390,16 @@ class Gitea extends Controller $skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload)); } if ($x_gitea_event === 'pull_request') { - $action = $this->webhookPayloadString($payload, 'action'); $full_name = data_get($payload, 'repository.full_name'); - $pull_request_id = $this->webhookPullRequestId($payload, 'number'); - $pull_request_html_url = $this->webhookPayloadUrl($payload, 'pull_request.html_url'); - $skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pull_request.title')]); $branch = $this->webhookString(data_get($payload, 'pull_request.head.ref')); $base_branch = $this->webhookString(data_get($payload, 'pull_request.base.ref')); - // Gitea sends the head commit in pull_request.head.sha; newer versions also send it as after. - $commit = $this->webhookCommitSha($payload, 'pull_request.head.sha') ?? $this->webhookCommitSha($payload, 'after'); + [ + 'action' => $action, + 'pull_request_id' => $pull_request_id, + 'pull_request_html_url' => $pull_request_html_url, + 'commit' => $commit, + 'skip_deploy' => $skip_deploy_pr, + ] = $this->readPullRequestPayload($payload); } if (! $branch || ($x_gitea_event === 'pull_request' && ! $base_branch)) { return response('Nothing to do. No branch found in the request.'); @@ -303,4 +642,23 @@ class Gitea extends Controller return handleError($e); } } + + /** + * Read and validate the pull_request payload fields that the handlers use. + * + * @return array{action: ?string, pull_request_id: int, pull_request_html_url: ?string, commit: ?string, skip_deploy: bool} + * + * @throws InvalidWebhookPayloadException When a field has a wrong type or format. + */ + private function readPullRequestPayload(mixed $payload): array + { + return [ + 'action' => $this->webhookPayloadString($payload, 'action'), + 'pull_request_id' => $this->webhookPullRequestId($payload, 'number'), + 'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'pull_request.html_url'), + // Gitea sends the head commit in pull_request.head.sha; newer versions also send it as after. + 'commit' => $this->webhookCommitSha($payload, 'pull_request.head.sha') ?? $this->webhookCommitSha($payload, 'after'), + 'skip_deploy' => self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pull_request.title')]), + ]; + } } diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index e615abd3c6..77e2979ccf 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -483,6 +483,7 @@ class Github extends Controller } $applications = Application::where('repository_project_id', $id) ->where('source_id', $github_app->id) + ->where('source_type', GithubApp::class) ->whereRelation('source', 'is_public', false); if ($x_github_event === 'push') { $applications = $applications->where('git_branch', $branch)->get(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7e30184241..41c5cbb18d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -15,6 +15,7 @@ use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\Server; @@ -110,7 +111,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private ?string $dockerImagePreviewTag = null; - private GithubApp|GitlabApp|string $source = 'other'; + private GithubApp|GitlabApp|GiteaApp|string $source = 'other'; private StandaloneDocker|SwarmDocker $destination; diff --git a/app/Livewire/GlobalSearch.php b/app/Livewire/GlobalSearch.php index 3e9733f063..772e4af3d1 100644 --- a/app/Livewire/GlobalSearch.php +++ b/app/Livewire/GlobalSearch.php @@ -187,6 +187,8 @@ class GlobalSearch extends Component 'new public repository' => 'public', 'new private github' => 'private-gh-app', 'new private gh' => 'private-gh-app', + 'new private gitea' => 'private-gitea-app', + 'new gitea' => 'private-gitea-app', 'new private deploy' => 'private-deploy-key', 'new deploy key' => 'private-deploy-key', @@ -240,7 +242,7 @@ class GlobalSearch extends Component if (in_array($type, [ 'project', 'source', // Applications - 'public', 'private-gh-app', 'private-deploy-key', + 'public', 'private-gh-app', 'private-gitea-app', 'private-deploy-key', 'dockerfile', 'docker-compose-empty', 'docker-image', // Databases 'postgresql', 'mysql', 'mariadb', 'redis', 'keydb', @@ -1210,6 +1212,15 @@ class GlobalSearch extends Component 'resourceType' => 'application', ]); + $items->push([ + 'name' => 'Private Repository (Gitea App)', + 'description' => 'Deploy private repositories through Gitea Apps', + 'quickcommand' => '(type: new private gitea)', + 'type' => 'private-gitea-app', + 'category' => 'Applications', + 'resourceType' => 'application', + ]); + $items->push([ 'name' => 'Private Repository (Deploy Key)', 'description' => 'Deploy private repositories with a deploy key', diff --git a/app/Livewire/Project/Application/Source.php b/app/Livewire/Project/Application/Source.php index 5b6ad61eae..d2843a6780 100644 --- a/app/Livewire/Project/Application/Source.php +++ b/app/Livewire/Project/Application/Source.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\Application; use App\Models\Application; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; @@ -104,7 +105,7 @@ class Source extends Component if ($source instanceof GithubApp) { return ! is_null($source->app_id); } - if ($source instanceof GitlabApp) { + if ($source instanceof GitlabApp || $source instanceof GiteaApp) { return $source->isConnected(); } @@ -149,7 +150,7 @@ class Source extends Component { try { $this->authorize('update', $this->application); - $allowedSourceTypes = [GithubApp::class, GitlabApp::class]; + $allowedSourceTypes = [GithubApp::class, GitlabApp::class, GiteaApp::class]; abort_unless(in_array($sourceType, $allowedSourceTypes, true), 404); $teamId = $this->application->team()->id; $source = $sourceType::query() @@ -173,6 +174,12 @@ class Source extends Component $project = gitlabApi($source, "/projects/{$encoded}"); $repository_project_id = data_get($project, 'data.id'); } + } elseif ($sourceType === GiteaApp::class) { + if ($source->isConnected()) { + $repositoryPath = collect(explode('/', $customRepository, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); + $repository = giteaApi($source, "/repos/{$repositoryPath}"); + $repository_project_id = data_get($repository, 'data.id'); + } } if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) { diff --git a/app/Livewire/Project/New/GiteaPrivateRepository.php b/app/Livewire/Project/New/GiteaPrivateRepository.php new file mode 100644 index 0000000000..83247821a9 --- /dev/null +++ b/app/Livewire/Project/New/GiteaPrivateRepository.php @@ -0,0 +1,245 @@ +where('id', $this->gitea_app_id)->firstOrFail(); + } + + public function mount() + { + $this->currentRoute = Route::currentRouteName(); + $this->parameters = get_route_parameters(); + $this->query = request()->query(); + $this->repositories = $this->branches = collect(); + $this->gitea_apps = GiteaApp::private()->select(['id', 'name', 'html_url', 'team_id', 'is_system_wide', 'is_public'])->get(); + } + + public function updatedSelectedProjectId(): void + { + $this->loadBranches(); + } + + public function updatedBuildPack() + { + if ($this->build_pack === 'nixpacks' || $this->build_pack === 'railpack') { + $this->show_is_static = true; + if (! $this->is_static) { + $this->port = 3000; + } + } elseif ($this->build_pack === 'static') { + $this->show_is_static = false; + $this->is_static = false; + $this->port = 80; + } else { + $this->show_is_static = false; + $this->is_static = false; + } + } + + public function loadRepositories($gitea_app_id) + { + $this->repositories = collect(); + $this->branches = collect(); + $this->total_branches_count = 0; + $this->page = 1; + $this->selected_gitea_app_id = $gitea_app_id; + $this->gitea_app_id = $gitea_app_id; + $gitea_app = $this->getGiteaApp(); + + try { + $result = loadGiteaRepositories($gitea_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + + while ($result['has_more'] && $this->page < 50) { + $this->page++; + $result = loadGiteaRepositories($gitea_app, $this->page); + $this->repositories = $this->repositories->concat(collect($result['repositories'])); + } + $this->total_repositories_count = $this->repositories->count(); + + $this->repositories = $this->repositories->sortBy('name'); + if ($this->repositories->count() > 0) { + $first = $this->repositories->first(); + $this->selected_project_id = data_get($first, 'id'); + $this->selected_repository_path = data_get($first, 'full_name'); + } + $this->current_step = 'repository'; + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function loadBranches() + { + $repo = $this->repositories->where('id', $this->selected_project_id)->first(); + $this->selected_repository_path = data_get($repo, 'full_name', $this->selected_repository_path); + $this->branches = collect(); + $this->page = 1; + $gitea_app = $this->getGiteaApp(); + + try { + $branches = loadGiteaBranches($gitea_app, $this->selected_repository_path, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + + while ($this->total_branches_count === GITEA_PAGE_SIZE && $this->page < 50) { + $this->page++; + $branches = loadGiteaBranches($gitea_app, $this->selected_repository_path, $this->page); + $this->total_branches_count = count($branches); + $this->branches = $this->branches->concat(collect($branches)); + } + + $this->branches = sortBranchesByPriority($this->branches); + $defaultBranch = data_get($repo, 'default_branch', 'main'); + $this->selected_branch_name = $this->branches->contains('name', $defaultBranch) ? $defaultBranch : data_get($this->branches, '0.name', 'main'); + } catch (\Throwable $e) { + return $this->dispatch('error', $e->getMessage()); + } + } + + public function submit() + { + try { + $this->authorize('create', Application::class); + + $validator = validator([ + 'selected_repository_path' => $this->selected_repository_path, + 'selected_branch_name' => $this->selected_branch_name, + 'docker_compose_location' => $this->docker_compose_location, + ], [ + 'selected_repository_path' => 'required|string', + 'selected_branch_name' => ['required', 'string', new ValidGitBranch], + 'docker_compose_location' => ValidationPatterns::filePathRules(), + ]); + + if ($validator->fails()) { + throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first()); + } + + $destination_uuid = $this->query['destination'] ?? null; + $destination = find_destination_for_current_team($destination_uuid); + if (! $destination) { + throw new \Exception('Destination not found.'); + } + $destination_class = $destination->getMorphClass(); + + $project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail(); + $environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail(); + + $gitea_app = $this->getGiteaApp(); + + $application = Application::create([ + 'name' => generate_application_name($this->selected_repository_path, $this->selected_branch_name), + 'repository_project_id' => $this->selected_project_id, + 'git_repository' => $this->selected_repository_path, + 'git_branch' => str($this->selected_branch_name)->trim()->toString(), + 'build_pack' => $this->build_pack, + 'ports_exposes' => $this->port, + 'publish_directory' => $this->publish_directory, + 'base_directory' => $this->base_directory, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination_class, + 'source_id' => $gitea_app->id, + 'source_type' => $gitea_app->getMorphClass(), + ]); + $application->settings->is_static = $this->is_static; + $application->settings->save(); + + if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') { + $application->health_check_enabled = false; + } + if ($this->build_pack === 'dockercompose') { + $application['docker_compose_location'] = $this->docker_compose_location; + } + $fqdn = generateUrl(server: $destination->server, random: $application->uuid); + $application->fqdn = $fqdn; + $application->name = generate_application_name($this->selected_repository_path, $this->selected_branch_name, $application->uuid); + $application->save(); + + return redirect()->route('project.application.configuration', [ + 'application_uuid' => $application->uuid, + 'environment_uuid' => $environment->uuid, + 'project_uuid' => $project->uuid, + ]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + if ($this->is_static) { + $this->port = 80; + $this->publish_directory = '/dist'; + } else { + $this->port = 3000; + $this->publish_directory = null; + } + $this->dispatch('success', 'Application settings updated!'); + } +} diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php index 5a777d7a59..2a68a7770d 100644 --- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php +++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\New; use App\Models\Application; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; @@ -55,7 +56,7 @@ class GithubPrivateRepositoryDeployKey extends Component private object $repository_url_parsed; - private GithubApp|GitlabApp|string $git_source = 'other'; + private GithubApp|GitlabApp|GiteaApp|string $git_source = 'other'; private ?string $git_host = null; @@ -141,7 +142,7 @@ class GithubPrivateRepositoryDeployKey extends Component return $this->unsavedApplicationForDetection( $this->git_repository, $this->branch ?? 'main', - $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp || $this->git_source instanceof GiteaApp ? $this->git_source : null, PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id), ); } diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php index a898204fc2..6c17b2810a 100644 --- a/app/Livewire/Project/New/PublicGitRepository.php +++ b/app/Livewire/Project/New/PublicGitRepository.php @@ -3,6 +3,7 @@ namespace App\Livewire\Project\New; use App\Models\Application; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\Project; @@ -52,7 +53,7 @@ class PublicGitRepository extends Component private object $repository_url_parsed; - public GithubApp|GitlabApp|string $git_source = 'other'; + public GithubApp|GitlabApp|GiteaApp|string $git_source = 'other'; public string $git_host; @@ -198,7 +199,7 @@ class PublicGitRepository extends Component return $this->unsavedApplicationForDetection( $this->git_repository, $this->git_branch, - $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null, + $this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp || $this->git_source instanceof GiteaApp ? $this->git_source : null, ); } diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index 2549cf5239..9ada4fd0fb 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -180,6 +180,12 @@ class Select extends Component 'description' => 'Deploy public or private GitLab projects through a GitLab App, with automatic webhooks and merge request previews.', 'logo' => resource_logo_url('gitlab-app'), ], + [ + 'id' => 'private-gitea-app', + 'name' => 'Git Repository (with Gitea App)', + 'description' => 'Deploy public or private Gitea repositories through a Gitea App, with automatic webhooks and pull request previews.', + 'logo' => asset('svgs/gitea.svg'), + ], ]; $dockerBasedApplications = [ [ diff --git a/app/Livewire/Source/Gitea/Change.php b/app/Livewire/Source/Gitea/Change.php new file mode 100644 index 0000000000..e66cc355cb --- /dev/null +++ b/app/Livewire/Source/Gitea/Change.php @@ -0,0 +1,363 @@ + 'required|string', + 'apiUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'htmlUrl' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'customUser' => 'required|string', + 'customPort' => 'required|int', + 'clientId' => 'nullable|string', + 'clientSecretInput' => 'nullable|string', + 'webhookSecret' => 'nullable|string', + 'organization' => 'nullable|string', + 'isSystemWide' => 'required|bool', + 'privateKeyId' => ['nullable', 'integer', Rule::exists('private_keys', 'id')->where('team_id', $this->gitea_app->team_id)], + 'webhook_endpoint' => ['required', 'string', 'url'], + 'custom_webhook_endpoint' => ['nullable', 'string', 'url'], + 'use_custom_webhook_endpoint' => ['required', 'bool'], + ]; + } + + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl) + || $this->apiUrl === rtrim($this->htmlUrl, '/').'/api/v1'; + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->apiUrl = rtrim($this->htmlUrl, '/').'/api/v1'; + } + } + + public function updatedWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + public function updatedUseCustomWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + public function updatedCustomWebhookEndpoint(): void + { + $this->persistRedirectUriFromEndpoint(); + } + + private function persistRedirectUriFromEndpoint(): void + { + $this->refreshRedirectUri(); + + if (! $this->gitea_app || blank($this->redirectUri)) { + return; + } + + try { + $this->authorize('update', $this->gitea_app); + if ($this->gitea_app->redirect_uri !== $this->redirectUri) { + $this->gitea_app->redirect_uri = $this->redirectUri; + $this->gitea_app->save(); + } + } catch (\Throwable) { + // Keep the live redirect URI even if the user cannot persist yet. + } + } + + public function mount() + { + try { + $gitea_app_uuid = request()->gitea_app_uuid; + $this->gitea_app = GiteaApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->whereUuid($gitea_app_uuid)->firstOrFail(); + + $this->privateKeys = PrivateKey::ownedByCurrentTeamCached(); + $this->applications = $this->gitea_app->applications; + + $settings = instanceSettings(); + + $this->syncData(false); + + $this->isConnected = $this->gitea_app->isConnected(); + $this->fqdn = $settings->fqdn; + + if ($settings->public_ipv4) { + $this->ipv4 = 'http://'.$settings->public_ipv4.':'.config('app.port'); + } + if ($settings->public_ipv6) { + $this->ipv6 = 'http://'.$settings->public_ipv6.':'.config('app.port'); + } + + $this->parameters = get_route_parameters(); + + if (isCloud() && ! isDev()) { + $this->webhook_endpoint = config('app.url'); + } else { + $this->webhook_endpoint = $this->fqdn ?? $this->ipv4 ?? $this->ipv6 ?? config('app.url') ?? ''; + } + + // Prefer a previously saved redirect base when it matches one of the selectable endpoints + // or when it differs (restore custom mode for self-hosted / tunnel setups). + $savedRedirect = $this->gitea_app->redirect_uri; + if (filled($savedRedirect)) { + $savedBase = rtrim(str($savedRedirect)->before('/webhooks/source/gitea/redirect')->toString(), '/'); + $known = collect([$this->fqdn, $this->ipv4, $this->ipv6, config('app.url')]) + ->filter() + ->map(fn ($url) => rtrim((string) $url, '/')); + + if ($known->contains($savedBase)) { + $this->webhook_endpoint = $savedBase; + $this->use_custom_webhook_endpoint = false; + } elseif (! (isCloud() && ! isDev()) && filled($savedBase)) { + $this->use_custom_webhook_endpoint = true; + $this->custom_webhook_endpoint = $savedBase; + } + } + + $this->refreshRedirectUri(); + + $this->oauthState = $this->createOAuthState(); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function refreshRedirectUri(): void + { + $base = $this->resolvePublicBaseUrl(); + $this->redirectUri = $base === '' + ? '' + : $base.'/webhooks/source/gitea/redirect'; + } + + public function resolvePublicBaseUrl(): string + { + if ($this->use_custom_webhook_endpoint && filled($this->custom_webhook_endpoint)) { + return rtrim($this->custom_webhook_endpoint, '/'); + } + + return rtrim($this->webhook_endpoint ?: (config('app.url') ?? ''), '/'); + } + + public static function oauthStateCacheKey(string $state): string + { + return 'gitea-app-oauth-state:'.hash('sha256', $state); + } + + private function createOAuthState(): string + { + $state = Str::random(64); + + Cache::put(self::oauthStateCacheKey($state), [ + 'gitea_app_id' => $this->gitea_app->id, + 'team_id' => currentTeam()->id, + ], now()->addMinutes(60)); + + return $state; + } + + private function syncData(bool $toModel = false): void + { + if ($toModel) { + $this->gitea_app->name = $this->name; + $this->gitea_app->api_url = $this->apiUrl; + $this->gitea_app->html_url = rtrim($this->htmlUrl, '/'); + $this->gitea_app->custom_user = $this->customUser; + $this->gitea_app->custom_port = $this->customPort; + $this->gitea_app->client_id = $this->clientId; + if (! empty($this->clientSecretInput)) { + $this->gitea_app->client_secret = $this->clientSecretInput; + } + if (! empty($this->webhookSecret)) { + $this->gitea_app->webhook_secret = $this->webhookSecret; + } + $this->gitea_app->organization = $this->organization; + $this->gitea_app->is_system_wide = $this->isSystemWide; + $this->gitea_app->private_key_id = $this->privateKeyId; + $this->refreshRedirectUri(); + $this->gitea_app->redirect_uri = $this->redirectUri; + } else { + $this->name = $this->gitea_app->name; + $this->apiUrl = $this->gitea_app->api_url; + $this->htmlUrl = $this->gitea_app->html_url; + $this->customUser = $this->gitea_app->custom_user; + $this->customPort = $this->gitea_app->custom_port; + $this->clientId = $this->gitea_app->client_id; + if (Gate::allows('update', $this->gitea_app)) { + $this->clientSecretInput = $this->gitea_app->client_secret; + $this->webhookSecret = $this->gitea_app->webhook_secret; + } + $this->organization = $this->gitea_app->organization; + $this->isSystemWide = $this->gitea_app->is_system_wide; + $this->privateKeyId = $this->gitea_app->private_key_id; + } + } + + public function submit() + { + try { + $this->authorize('update', $this->gitea_app); + + $this->validate(); + + $this->syncData(true); + $this->gitea_app->save(); + $this->dispatch('success', 'Gitea App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function instantSave() + { + try { + $this->authorize('update', $this->gitea_app); + + $this->validateOnly('isSystemWide'); + + $this->gitea_app->is_system_wide = $this->isSystemWide; + $this->gitea_app->save(); + $this->dispatch('success', 'Gitea App updated.'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function testConnection() + { + try { + $this->authorize('view', $this->gitea_app); + + if (! $this->gitea_app->isConnected()) { + $this->dispatch('error', 'Gitea App is not connected. Please complete the OAuth flow first.'); + + return; + } + + refreshGiteaToken($this->gitea_app); + + $apiUrl = $this->gitea_app->apiUrlBase(); + $response = Http::Gitea($apiUrl, $this->gitea_app->access_token) + ->timeout(10) + ->get('/user'); + + if ($response->successful()) { + $login = data_get($response->json(), 'login', 'unknown'); + $this->dispatch('success', "Connection successful! Authenticated as: {$login}"); + } else { + $error = data_get($response->json(), 'message', 'Unknown error'); + $this->dispatch('error', "Connection failed: {$error}"); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function delete() + { + try { + $this->authorize('delete', $this->gitea_app); + + if ($this->gitea_app->applications->isNotEmpty()) { + $this->dispatch('error', 'This source is being used by an application. Please delete all applications first.'); + + return; + } + $this->gitea_app->delete(); + // Clear so post-delete Livewire re-render / modal $refresh does not re-run + // @can and canGate checks against a deleted model (null team_id TypeError). + $this->gitea_app = null; + + return redirectRoute($this, 'source.all'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function getOAuthUrl(): string + { + $this->refreshRedirectUri(); + $baseUrl = rtrim($this->htmlUrl, '/'); + + $query = http_build_query([ + 'client_id' => $this->clientId, + 'redirect_uri' => $this->redirectUri, + 'response_type' => 'code', + 'scope' => 'read:user read:repository read:organization', + 'state' => $this->oauthState ??= $this->createOAuthState(), + ]); + + return "{$baseUrl}/login/oauth/authorize?{$query}"; + } +} diff --git a/app/Livewire/Source/Gitea/Create.php b/app/Livewire/Source/Gitea/Create.php new file mode 100644 index 0000000000..5b7e2a7719 --- /dev/null +++ b/app/Livewire/Source/Gitea/Create.php @@ -0,0 +1,98 @@ +name = substr(generate_random_name(), 0, 30); + } + + public function updatingHtmlUrl(): void + { + $this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url) + || $this->api_url === $this->giteaApiUrlFromHtmlUrl($this->html_url); + } + + public function updatedHtmlUrl(): void + { + if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) { + $this->api_url = $this->giteaApiUrlFromHtmlUrl($this->html_url); + } + } + + public function createGiteaApp() + { + try { + $this->authorize('createAnyResource'); + + $this->html_url = rtrim($this->html_url, '/'); + $this->api_url = filled($this->api_url) + ? rtrim($this->api_url, '/') + : $this->giteaApiUrlFromHtmlUrl($this->html_url); + + $this->validate([ + 'name' => 'required|string', + 'html_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'api_url' => ['required', 'string', 'url', SafeExternalUrl::forGitSource()], + 'custom_user' => 'required|string', + 'custom_port' => 'required|int', + 'is_system_wide' => 'required|bool', + 'organization' => 'nullable|string', + ]); + + $gitea_app = GiteaApp::create([ + 'name' => $this->name, + 'api_url' => $this->api_url, + 'html_url' => $this->html_url, + 'custom_user' => $this->custom_user, + 'custom_port' => $this->custom_port, + 'is_system_wide' => $this->is_system_wide, + 'organization' => $this->organization, + 'webhook_secret' => Str::random(32), + 'team_id' => currentTeam()->id, + ]); + + if (session('from')) { + session(['from' => session('from') + ['source_id' => $gitea_app->id]]); + } + + return redirectRoute($this, 'source.gitea.show', ['gitea_app_uuid' => $gitea_app->uuid]); + } catch (ValidationException $e) { + throw $e; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + private function giteaApiUrlFromHtmlUrl(string $htmlUrl): string + { + return rtrim($htmlUrl, '/').'/api/v1'; + } +} diff --git a/app/Models/Application.php b/app/Models/Application.php index 84ece43546..c20d2c745e 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -781,6 +781,9 @@ class Application extends BaseModel get: function () { $base_dir = $this->base_directory ?? '/'; if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if ($this->source instanceof GiteaApp) { + return "{$this->source->html_url}/{$this->git_repository}/src/branch/{$this->git_branch}{$base_dir}"; + } if (str($this->git_repository)->contains('bitbucket')) { return "{$this->source->html_url}/{$this->git_repository}/src/{$this->git_branch}{$base_dir}"; } @@ -823,6 +826,10 @@ class Application extends BaseModel return Attribute::make( get: function () { if (! is_null($this->source?->html_url) && ! is_null($this->git_repository) && ! is_null($this->git_branch)) { + if ($this->source instanceof GiteaApp) { + return "{$this->source->html_url}/{$this->git_repository}/commits/branch/{$this->git_branch}"; + } + return "{$this->source->html_url}/{$this->git_repository}/commits/{$this->git_branch}"; } $httpsRepository = $this->httpsUrlFromSshGitRepository(); @@ -1688,14 +1695,15 @@ class Application extends BaseModel ]; } - if ($this->source->getMorphClass() === GitlabApp::class) { - $gitlabSource = $this->source; + if (in_array($this->source->getMorphClass(), [GitlabApp::class, GiteaApp::class], true)) { + $oauthSource = $this->source; + $isGiteaSource = $oauthSource instanceof GiteaApp; - if ($gitlabSource->isConnected()) { + if ($oauthSource->isConnected()) { $url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: []; $source_html_url_host = $this->urlHostWithPort($url); $source_html_url_scheme = $url['scheme'] ?? ''; - $token = generateGitlabCloneToken($gitlabSource); + $token = $isGiteaSource ? generateGiteaCloneToken($oauthSource) : generateGitlabCloneToken($oauthSource); $encodedToken = rawurlencode($token); $pathPrefix = rtrim($url['path'] ?? '', '/'); $repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git"; @@ -1716,12 +1724,12 @@ class Application extends BaseModel ]; } - $private_key = gitlabAppPrivateKey($gitlabSource)?->private_key; + $private_key = ($isGiteaSource ? giteaAppPrivateKey($oauthSource) : gitlabAppPrivateKey($oauthSource))?->private_key; if ($private_key) { $fullRepoUrl = $customRepository; $private_key = base64_encode($private_key); - $gitlabPort = $gitlabSource->custom_port ?? 22; + $gitlabPort = $oauthSource->custom_port ?? 22; $escapedCustomRepository = str_replace("'", "'\\''", $customRepository); $base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'"; @@ -1943,11 +1951,13 @@ class Application extends BaseModel ]; } - if ($this->source->getMorphClass() === GitlabApp::class) { - $gitlabSource = $this->source; + if (in_array($this->source->getMorphClass(), [GitlabApp::class, GiteaApp::class], true)) { + $oauthSource = $this->source; + $isGiteaSource = $oauthSource instanceof GiteaApp; + $pullRequestRef = $isGiteaSource ? "pull/{$pull_request_id}/head" : "merge-requests/{$pull_request_id}/head"; - if ($gitlabSource->isConnected()) { - $token = generateGitlabCloneToken($gitlabSource); + if ($oauthSource->isConnected()) { + $token = $isGiteaSource ? generateGiteaCloneToken($oauthSource) : generateGitlabCloneToken($oauthSource); $encodedToken = rawurlencode($token); $pathPrefix = rtrim($url['path'] ?? '', '/'); $source_html_url_host = $this->urlHostWithPort($url ?: []); @@ -1967,7 +1977,7 @@ class Application extends BaseModel } if ($pull_request_id !== 0) { - $branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}"; + $branch = "{$pullRequestRef}:{$pr_branch_name}"; if ($exec_in_docker) { $commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'")); } else { @@ -1991,12 +2001,12 @@ class Application extends BaseModel ]; } - $private_key = gitlabAppPrivateKey($gitlabSource)?->private_key; + $private_key = ($isGiteaSource ? giteaAppPrivateKey($oauthSource) : gitlabAppPrivateKey($oauthSource))?->private_key; if ($private_key) { $fullRepoUrl = $customRepository; $private_key = base64_encode($private_key); - $gitlabPort = $gitlabSource->custom_port ?? 22; + $gitlabPort = $oauthSource->custom_port ?? 22; $escapedCustomRepository = escapeshellarg($customRepository); $gitlabSshCommand = "ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes"; $gitlabGitSshCommand = "GIT_SSH_COMMAND=\"{$gitlabSshCommand}\""; @@ -2009,7 +2019,7 @@ class Application extends BaseModel $commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker); if ($pull_request_id !== 0) { - $branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name"; + $branch = "{$pullRequestRef}:$pr_branch_name"; if ($exec_in_docker) { $commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"))); } else { diff --git a/app/Models/GiteaApp.php b/app/Models/GiteaApp.php new file mode 100644 index 0000000000..cee2c41a58 --- /dev/null +++ b/app/Models/GiteaApp.php @@ -0,0 +1,120 @@ + 'encrypted', + 'refresh_token' => 'encrypted', + 'client_secret' => 'encrypted', + 'webhook_secret' => 'encrypted', + 'is_system_wide' => 'boolean', + 'is_public' => 'boolean', + ]; + } + + /** + * Find the source whose webhook secret signed the payload. + * Encrypted secrets cannot be matched with SQL; sources are few per instance. + */ + public static function findBySignature(string $payload, string $signature): ?self + { + if ($signature === '') { + return null; + } + + return static::query()->whereNotNull('webhook_secret')->get()->first( + fn (self $app): bool => filled($app->webhook_secret) + && hash_equals(hash_hmac('sha256', $payload, (string) $app->webhook_secret), $signature) + ); + } + + protected static function booted(): void + { + static::deleting(function (GiteaApp $giteaApp) { + if ($giteaApp->applications()->count() > 0) { + throw new \RuntimeException('This source is being used by an application. Please delete all applications first.'); + } + }); + } + + public static function ownedByCurrentTeam() + { + return GiteaApp::where(function ($query) { + $query->where('team_id', currentTeam()->id) + ->orWhere('is_system_wide', true); + }); + } + + public static function private() + { + return GiteaApp::where(function ($query) { + $query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true); + })->where('is_public', false)->whereNotNull('access_token'); + } + + public function applications() + { + return $this->morphMany(Application::class, 'source'); + } + + public function privateKey() + { + return $this->belongsTo(PrivateKey::class); + } + + public function team() + { + return $this->belongsTo(Team::class); + } + + public function isConnected(): bool + { + return ! empty($this->access_token) && ! empty($this->refresh_token); + } + + public function apiUrlBase(): string + { + $apiUrl = rtrim($this->api_url, '/'); + + if (! str_contains($apiUrl, '/api/v1')) { + $apiUrl .= '/api/v1'; + } + + return $apiUrl; + } +} diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php index 49e32e0b85..03e227ef5e 100644 --- a/app/Models/GithubApp.php +++ b/app/Models/GithubApp.php @@ -88,7 +88,7 @@ class GithubApp extends BaseModel protected static function booted(): void { static::deleting(function (GithubApp $github_app) { - $applications_count = Application::where('source_id', $github_app->id)->count(); + $applications_count = $github_app->applications()->count(); if ($applications_count > 0) { throw new \Exception('You cannot delete this GitHub App because it is in use by '.$applications_count.' application(s). Delete them first.'); } @@ -108,7 +108,8 @@ class GithubApp extends BaseModel $isUsedElsewhere = $privateKey->servers()->exists() || $privateKey->applications()->exists() || $privateKey->githubApps()->where('id', '!=', $github_app->id)->exists() - || $privateKey->gitlabApps()->exists(); + || $privateKey->gitlabApps()->exists() + || $privateKey->giteaApps()->exists(); if (! $isUsedElsewhere) { $privateKey->delete(); diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 22689dc055..6b25353038 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -324,12 +324,18 @@ class PrivateKey extends BaseModel return $this->hasMany(GitlabApp::class); } + public function giteaApps() + { + return $this->hasMany(GiteaApp::class); + } + public function isInUse() { return $this->servers()->exists() || $this->applications()->exists() || $this->githubApps()->exists() - || $this->gitlabApps()->exists(); + || $this->gitlabApps()->exists() + || $this->giteaApps()->exists(); } public function safeDelete() diff --git a/app/Models/Team.php b/app/Models/Team.php index a6a6f82dae..869bff88c5 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -100,14 +100,16 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen // Transfer instance-wide sources to root team so they remain available $systemWideSources = GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->get() - ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); + ->concat(GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->get()) + ->concat(GiteaApp::where('team_id', $team->id)->where('is_system_wide', true)->get()); foreach ($systemWideSources as $source) { $source->update(['team_id' => 0]); } // Delete non-instance-wide sources owned by this team $teamSources = GithubApp::where('team_id', $team->id)->get() - ->concat(GitlabApp::where('team_id', $team->id)->get()); + ->concat(GitlabApp::where('team_id', $team->id)->get()) + ->concat(GiteaApp::where('team_id', $team->id)->get()); foreach ($teamSources as $source) { $source->delete(); } @@ -343,7 +345,8 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen 'projects' => $this->projects()->count(), 'servers' => $this->servers()->count(), 'sources' => GithubApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count() - + GitlabApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count(), + + GitlabApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count() + + GiteaApp::query()->where('team_id', $this->id)->where('is_system_wide', false)->count(), ]); } @@ -406,7 +409,14 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen })->where('is_public', false); })->get(); - return $sources->merge($github_apps)->merge($gitlab_apps); + $gitea_apps = GiteaApp::where(function ($query) { + $query->where(function ($q) { + $q->where('team_id', $this->id) + ->orWhere('is_system_wide', true); + })->where('is_public', false); + })->get(); + + return $sources->merge($github_apps)->merge($gitlab_apps)->merge($gitea_apps); } public function s3s() diff --git a/app/Policies/GiteaAppPolicy.php b/app/Policies/GiteaAppPolicy.php new file mode 100644 index 0000000000..a269ead557 --- /dev/null +++ b/app/Policies/GiteaAppPolicy.php @@ -0,0 +1,87 @@ +is_system_wide) { + return true; + } + + return $user->teams->contains('id', $giteaApp->team_id); + } + + /** + * Determine whether the user can create models. + */ + public function create(User $user): bool + { + return $user->isAdmin(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, GiteaApp $giteaApp): bool + { + if ($giteaApp->is_system_wide) { + return $user->canAccessSystemResources(); + } + + // Guard null team_id (e.g. post-delete Livewire re-render of @can checks). + if ($giteaApp->team_id === null) { + return false; + } + + return $user->isAdminOfTeam($giteaApp->team_id); + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, GiteaApp $giteaApp): bool + { + if ($giteaApp->is_system_wide) { + return $user->canAccessSystemResources(); + } + + // Guard null team_id (e.g. post-delete Livewire re-render of @can checks). + if ($giteaApp->team_id === null) { + return false; + } + + return $user->isAdminOfTeam($giteaApp->team_id); + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, GiteaApp $giteaApp): bool + { + return false; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, GiteaApp $giteaApp): bool + { + return false; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1c36b36298..fb6d3ed150 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -145,5 +145,13 @@ class AppServiceProvider extends ServiceProvider return $access_token ? $client->withToken($access_token) : $client; }); + + Http::macro('Gitea', function (string $api_url, ?string $access_token = null) { + $client = Http::GitSource($api_url)->withHeaders([ + 'Accept' => 'application/json', + ])->baseUrl($api_url); + + return $access_token ? $client->withToken($access_token) : $client; + }); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index bab3d87c3b..c0a774daf2 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -12,6 +12,7 @@ use App\Models\DiscordNotificationSettings; use App\Models\EmailNotificationSettings; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; @@ -51,6 +52,7 @@ use App\Policies\CloudProviderTokenPolicy; use App\Policies\DatabasePolicy; use App\Policies\EnvironmentPolicy; use App\Policies\EnvironmentVariablePolicy; +use App\Policies\GiteaAppPolicy; use App\Policies\GithubAppPolicy; use App\Policies\GitlabAppPolicy; use App\Policies\InstanceSettingsPolicy; @@ -133,6 +135,7 @@ class AuthServiceProvider extends ServiceProvider // Git source policies GithubApp::class => GithubAppPolicy::class, GitlabApp::class => GitlabAppPolicy::class, + GiteaApp::class => GiteaAppPolicy::class, // Cloud provider policies CloudProviderToken::class => CloudProviderTokenPolicy::class, diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 94200d4169..1c615e304d 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -16,6 +16,7 @@ use App\Models\DockerCleanupExecution; use App\Models\EmailNotificationSettings; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GithubRunnerExecution; @@ -109,6 +110,7 @@ final class ServerTransferCoverage GithubApp::class => ['github_apps.*'], GithubRunnerConfig::class => ['github_runner_config'], GitlabApp::class => ['gitlab_apps.*'], + GiteaApp::class => ['gitea_apps.*'], S3Storage::class => ['s3_storages.*'], Project::class => ['projects.*'], Environment::class => ['projects.*.environments.*'], @@ -212,6 +214,7 @@ final class ServerTransferCoverage 'github_app_id' => 'Exported as github_app_uuid. '.self::LINKED, ], GitlabApp::class => ['team_id' => self::TEAM, 'private_key_id' => self::LINKED], + GiteaApp::class => ['team_id' => self::TEAM, 'private_key_id' => self::LINKED], S3Storage::class => ['team_id' => self::TEAM, 'unusable_email_sent' => self::RUNTIME], Project::class => [ 'team_id' => self::TEAM, diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index c80309cb9d..4b1255f6e5 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -8,6 +8,7 @@ use App\Models\CloudProviderToken; use App\Models\DnsProviderZone; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GitlabApp; @@ -168,6 +169,7 @@ class ServerTransferExporter 'private_keys' => $dependencies['private_keys']->map(fn (PrivateKey $key) => $this->exportPrivateKey($key))->values()->all(), 'github_apps' => $dependencies['github_apps']->map(fn (GithubApp $app) => $this->exportGithubApp($app))->values()->all(), 'gitlab_apps' => $dependencies['gitlab_apps']->map(fn (GitlabApp $app) => $this->exportGitlabApp($app))->values()->all(), + 'gitea_apps' => $dependencies['gitea_apps']->map(fn (GiteaApp $app) => $this->exportGiteaApp($app))->values()->all(), 's3_storages' => $dependencies['s3_storages']->map(fn (S3Storage $storage) => $this->exportS3Storage($storage))->values()->all(), 'cloud_provider_tokens' => $dependencies['cloud_provider_tokens']->map(fn (CloudProviderToken $token) => $this->exportCloudProviderToken($token))->values()->all(), 'integration_tokens' => $integrationTokens->map(fn (IntegrationToken $token) => $this->exportIntegrationToken($token))->values()->all(), @@ -196,6 +198,7 @@ class ServerTransferExporter * private_keys: Collection, * github_apps: Collection, * gitlab_apps: Collection, + * gitea_apps: Collection, * s3_storages: Collection, * cloud_provider_tokens: Collection * } @@ -206,6 +209,7 @@ class ServerTransferExporter $privateKeys = collect([$server->privateKey])->filter(); $githubApps = collect(); $gitlabApps = collect(); + $giteaApps = collect(); $s3Ids = collect(); // System-wide GitHub Apps are instance-owned; do not copy them. The application source and the runner @@ -243,6 +247,17 @@ class ServerTransferExporter } } } + + if ($application->source_type === GiteaApp::class && $application->source_id !== null) { + $gt = $this->findGitSource(GiteaApp::class, $application->source_id, $teamId); + // System-wide Gitea Apps are instance-owned; re-link by UUID on import. + if ($gt && ! $gt->is_system_wide) { + $giteaApps->push($gt); + if ($gt->private_key_id) { + $privateKeys->push($this->findTeamRecord(PrivateKey::class, $gt->private_key_id, $teamId, 'private key')); + } + } + } } $collectBackupS3 = function (ScheduledDatabaseBackup|ScheduledVolumeBackup $backup) use (&$s3Ids): void { @@ -313,6 +328,7 @@ class ServerTransferExporter 'private_keys' => $privateKeys->unique('id')->values(), 'github_apps' => $githubApps->unique('id')->values(), 'gitlab_apps' => $gitlabApps->unique('id')->values(), + 'gitea_apps' => $giteaApps->unique('id')->values(), 's3_storages' => $this->findTeamS3Storages($s3Ids->unique()->filter()->values()->all(), $teamId), 'cloud_provider_tokens' => $cloudTokens->unique('id')->values(), ]; @@ -340,9 +356,9 @@ class ServerTransferExporter /** * System-wide git sources are returned as they are (they are only linked by UUID, never exported). * - * @param class-string $modelClass + * @param class-string $modelClass */ - private function findGitSource(string $modelClass, int|string $id, int $teamId): GithubApp|GitlabApp|null + private function findGitSource(string $modelClass, int|string $id, int $teamId): GithubApp|GitlabApp|GiteaApp|null { $source = $modelClass::find($id); if ($source && ! $source->is_system_wide && (int) $source->team_id !== $teamId) { @@ -399,6 +415,18 @@ class ServerTransferExporter $warnings[] = 'Some applications use a system-wide GitLab App, which is not exported. On import, Coolify will re-link to a matching system-wide GitLab App on the target instance (by UUID) if one exists.'; } + $usesSystemWideGitea = $applications->contains(function (Application $app) { + if ($app->source_type !== GiteaApp::class || $app->source_id === null) { + return false; + } + $gt = GiteaApp::find($app->source_id); + + return $gt?->is_system_wide === true; + }); + if ($usesSystemWideGitea) { + $warnings[] = 'Some applications use a system-wide Gitea App, which is not exported. On import, Coolify will re-link to a matching system-wide Gitea App on the target instance (by UUID) if one exists.'; + } + if ($dependencies['s3_storages']->isNotEmpty()) { $warnings[] = 'S3 storage credentials were exported. Confirm endpoint access from the target instance and that bucket policies still apply.'; } @@ -505,7 +533,7 @@ class ServerTransferExporter /** * Manual steps after the transfer. {target_url} stands for the URL of the target instance. * - * @param array{github_apps: Collection, gitlab_apps: Collection} $dependencies + * @param array{github_apps: Collection, gitlab_apps: Collection, gitea_apps: Collection} $dependencies * @param Collection $applications * @return list */ @@ -531,6 +559,10 @@ class ServerTransferExporter foreach ($dependencies['gitlab_apps'] as $gitlabApp) { $steps[] = "In GitLab, open Settings > Webhooks of every project that uses GitLab App {$gitlabApp->name}, and set the webhook URL to {target_url}/webhooks/source/gitlab/events."; } + foreach ($dependencies['gitea_apps'] as $giteaApp) { + $steps[] = "In Gitea, open the OAuth2 application of Gitea App {$giteaApp->name} and set the redirect URI to {target_url}/webhooks/source/gitea/redirect. " + .'Then set the webhook URL of every repository that uses it to {target_url}/webhooks/source/gitea/events.'; + } if ($runnerConfig) { $steps[] = 'After the GitHub App sends webhooks to {target_url}, open this server there, check the runner requirements, and enable GitHub runners. ' .'Enabling them stops runners that another Coolify instance started on this server.'; @@ -901,6 +933,8 @@ class ServerTransferExporter $source = ['type' => 'github_app', 'uuid' => $application->source->uuid, 'system_wide' => (bool) $application->source->is_system_wide]; } elseif ($application->source_type === GitlabApp::class && $application->source) { $source = ['type' => 'gitlab_app', 'uuid' => $application->source->uuid, 'system_wide' => (bool) $application->source->is_system_wide]; + } elseif ($application->source_type === GiteaApp::class && $application->source) { + $source = ['type' => 'gitea_app', 'uuid' => $application->source->uuid, 'system_wide' => (bool) $application->source->is_system_wide]; } return [ @@ -1414,6 +1448,33 @@ class ServerTransferExporter ]; } + /** + * @return array + */ + private function exportGiteaApp(GiteaApp $app): array + { + return [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'organization' => $app->organization, + 'api_url' => $app->api_url, + 'html_url' => $app->html_url, + 'custom_port' => $app->custom_port, + 'custom_user' => $app->custom_user, + 'is_system_wide' => false, + 'is_public' => (bool) $app->is_public, + 'client_id' => $app->client_id, + 'client_secret' => $app->client_secret, + 'access_token' => $app->access_token, + 'refresh_token' => $app->refresh_token, + 'expires_at' => $app->expires_at, + 'redirect_uri' => $app->redirect_uri, + 'webhook_secret' => $app->webhook_secret, + 'private_key_uuid' => $app->privateKey?->uuid, + 'webhook_url_hint' => '/webhooks/source/gitea/events', + ]; + } + /** * @return array */ diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index d183f4d76b..11bd69663d 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -11,6 +11,7 @@ use App\Models\CloudProviderToken; use App\Models\DnsProviderZone; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GitlabApp; @@ -87,6 +88,9 @@ class ServerTransferImporter /** @var array */ private array $gitlabAppMap = []; + /** @var array */ + private array $giteaAppMap = []; + /** @var array */ private array $s3StorageMap = []; @@ -221,6 +225,7 @@ class ServerTransferImporter 'private_keys' => 0, 'github_apps' => 0, 'gitlab_apps' => 0, + 'gitea_apps' => 0, 's3_storages' => 0, 'cloud_provider_tokens' => 0, 'integration_tokens' => 0, @@ -250,6 +255,7 @@ class ServerTransferImporter ); $created['github_apps'] = count(data_get($bundle, 'github_apps', [])); $created['gitlab_apps'] = count(data_get($bundle, 'gitlab_apps', [])); + $created['gitea_apps'] = count(data_get($bundle, 'gitea_apps', [])); $created['s3_storages'] = count(data_get($bundle, 's3_storages', [])); $created['cloud_provider_tokens'] = count(data_get($bundle, 'cloud_provider_tokens', [])); $created['integration_tokens'] = count(data_get($bundle, 'integration_tokens', [])); @@ -281,6 +287,7 @@ class ServerTransferImporter $this->privateKeyMap = []; $this->githubAppMap = []; $this->gitlabAppMap = []; + $this->giteaAppMap = []; $this->s3StorageMap = []; $this->cloudTokenMap = []; $this->integrationTokenMap = []; @@ -331,6 +338,17 @@ class ServerTransferImporter $created['gitlab_apps']++; } + foreach (data_get($bundle, 'gitea_apps', []) as $gtPayload) { + // Defensive: never import system-wide Gitea Apps from a bundle. + if ((bool) data_get($gtPayload, 'is_system_wide', false)) { + continue; + } + $gt = $this->importGiteaApp($gtPayload, $teamId, $preserveUuids); + $this->giteaAppMap[(string) data_get($gtPayload, 'uuid', $gt->uuid)] = $gt; + $this->giteaAppMap[$gt->uuid] = $gt; + $created['gitea_apps']++; + } + foreach (data_get($bundle, 's3_storages', []) as $s3Payload) { $s3 = $this->importS3Storage($s3Payload, $teamId, $preserveUuids); $this->s3StorageMap[(string) data_get($s3Payload, 'uuid', $s3->uuid)] = $s3; @@ -1242,6 +1260,14 @@ class ServerTransferImporter $attributes['source_type'] = GitlabApp::class; $attributes['source_id'] = $gl->id; } + } elseif ($sourceType === 'gitea_app') { + $gt = $this->giteaAppMap[$sourceUuid] + ?? GiteaApp::query()->where('uuid', $sourceUuid)->where('is_system_wide', true)->first() + ?? GiteaApp::query()->where('uuid', $sourceUuid)->where('team_id', $teamId)->first(); + if ($gt) { + $attributes['source_type'] = GiteaApp::class; + $attributes['source_id'] = $gt->id; + } } } $sourceMissing = data_get($payload, 'had_git_source') && ! isset($attributes['source_id']); @@ -1252,6 +1278,7 @@ class ServerTransferImporter $sourceLabel = match (data_get($payload, 'source.type')) { 'github_app' => 'GitHub App', 'gitlab_app' => 'GitLab App', + 'gitea_app' => 'Gitea App', default => 'git source', }; $this->importWarnings[] = "Application {$name}: its {$sourceLabel} was not found on this instance; set the source again."; @@ -1890,6 +1917,55 @@ class ServerTransferImporter return $app; } + /** + * @param array $payload + */ + private function importGiteaApp(array $payload, int $teamId, bool $preserveUuids): GiteaApp + { + $uuid = $preserveUuids && filled(data_get($payload, 'uuid')) + ? (string) data_get($payload, 'uuid') + : new_public_id(); + + $existing = GiteaApp::where('uuid', $uuid)->where('team_id', $teamId)->first(); + if ($existing) { + return $existing; + } + if (GiteaApp::where('uuid', $uuid)->exists()) { + $uuid = new_public_id(); + } + + $privateKeyId = null; + $keyUuid = data_get($payload, 'private_key_uuid'); + if ($keyUuid && isset($this->privateKeyMap[$keyUuid])) { + $privateKeyId = $this->privateKeyMap[$keyUuid]->id; + } + + $app = new GiteaApp; + $app->forceFill([ + 'team_id' => $teamId, + 'private_key_id' => $privateKeyId, + 'name' => data_get($payload, 'name') ?: 'Imported Gitea App', + 'organization' => data_get($payload, 'organization'), + 'api_url' => data_get($payload, 'api_url'), + 'html_url' => data_get($payload, 'html_url'), + 'custom_port' => data_get($payload, 'custom_port') ?? 22, + 'custom_user' => data_get($payload, 'custom_user') ?? 'git', + 'is_system_wide' => false, + 'is_public' => (bool) data_get($payload, 'is_public', false), + 'client_id' => data_get($payload, 'client_id'), + 'client_secret' => data_get($payload, 'client_secret'), + 'access_token' => data_get($payload, 'access_token'), + 'refresh_token' => data_get($payload, 'refresh_token'), + 'expires_at' => data_get($payload, 'expires_at'), + 'redirect_uri' => data_get($payload, 'redirect_uri'), + 'webhook_secret' => data_get($payload, 'webhook_secret'), + ]); + $app->uuid = $uuid; + $app->save(); + + return $app; + } + /** * @param array $payload */ diff --git a/app/Traits/HasRepositoryDetection.php b/app/Traits/HasRepositoryDetection.php index 7a4fced3e4..d89d5314eb 100644 --- a/app/Traits/HasRepositoryDetection.php +++ b/app/Traits/HasRepositoryDetection.php @@ -6,6 +6,7 @@ use App\Data\RepositoryDetectionResult; use App\Models\Application; use App\Models\ApplicationSetting; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\PrivateKey; @@ -111,7 +112,7 @@ trait HasRepositoryDetection /** * Builds an application that is not saved, only to generate the clone command for the scan. */ - protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application + protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|GiteaApp|null $source = null, ?PrivateKey $privateKey = null): Application { $application = (new Application)->forceFill([ 'git_repository' => $repository, diff --git a/bootstrap/helpers/gitea.php b/bootstrap/helpers/gitea.php new file mode 100644 index 0000000000..b1911c481b --- /dev/null +++ b/bootstrap/helpers/gitea.php @@ -0,0 +1,163 @@ +refresh_token) { + throw new RuntimeException('Gitea source has no refresh token. Please reconnect the Gitea app.'); + } + + $safetyMargin = 60; + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $lock = Cache::lock("gitea_token_refresh_{$source->id}", 20); + if (! $lock->block(20)) { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + throw new RuntimeException('Gitea token refresh timed out. Please try again.'); + } + + try { + $source->refresh(); + if ($source->expires_at && $source->expires_at > time() + $safetyMargin) { + return; + } + + $baseUrl = rtrim($source->html_url, '/'); + + $response = Http::GitSource($baseUrl)->asForm()->post("{$baseUrl}/login/oauth/access_token", [ + 'client_id' => $source->client_id, + 'client_secret' => $source->client_secret, + 'refresh_token' => $source->refresh_token, + 'grant_type' => 'refresh_token', + ]); + + if (! $response->successful()) { + $error = data_get($response->json(), 'error_description', $response->body()); + throw new RuntimeException("Failed to refresh Gitea token: {$error}"); + } + + $data = $response->json(); + $source->update([ + 'access_token' => $data['access_token'], + 'refresh_token' => $data['refresh_token'] ?? $source->refresh_token, + 'expires_at' => time() + ($data['expires_in'] ?? 3600), + ]); + } finally { + $lock->release(); + } +} + +function giteaApi(GiteaApp $source, string $endpoint, string $method = 'get', ?array $data = null): array +{ + refreshGiteaToken($source); + + $client = Http::Gitea($source->apiUrlBase(), $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false); + + if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { + $response = $client->$method($endpoint, $data); + } else { + $response = $client->$method($endpoint); + } + + if (! $response->successful()) { + $errorMessage = data_get($response->json(), 'message', $response->body()); + throw new RuntimeException("Gitea API call failed: {$errorMessage}"); + } + + return [ + 'data' => collect($response->json()), + 'total' => (int) $response->header('x-total-count', 0), + ]; +} + +function giteaAppPrivateKey(GiteaApp $source): ?PrivateKey +{ + $privateKey = $source->privateKey; + + return $privateKey?->team_id === $source->team_id ? $privateKey : null; +} + +function generateGiteaCloneToken(GiteaApp $source): string +{ + refreshGiteaToken($source); + + return $source->access_token; +} + +function loadGiteaRepositories(GiteaApp $source, int $page = 1): array +{ + refreshGiteaToken($source); + + $response = Http::Gitea($source->apiUrlBase(), $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get('/user/repos', [ + 'limit' => GITEA_PAGE_SIZE, + 'page' => $page, + ]); + + if (! $response->successful()) { + return ['total_count' => 0, 'has_more' => false, 'repositories' => []]; + } + + $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'))) + ); + } + + return [ + 'total_count' => $repositories->count(), + 'has_more' => $hasMore, + 'repositories' => $repositories->map(fn ($repository) => [ + 'id' => data_get($repository, 'id'), + 'name' => data_get($repository, 'name'), + 'full_name' => data_get($repository, 'full_name'), + 'default_branch' => data_get($repository, 'default_branch', 'main'), + 'html_url' => data_get($repository, 'html_url'), + 'owner' => data_get($repository, 'owner.login'), + ])->values()->all(), + ]; +} + +function loadGiteaBranches(GiteaApp $source, string $fullName, int $page = 1): array +{ + refreshGiteaToken($source); + + $repositoryPath = collect(explode('/', $fullName, 2))->map(fn ($part) => rawurlencode($part))->implode('/'); + $response = Http::Gitea($source->apiUrlBase(), $source->access_token) + ->timeout(20) + ->retry(3, 200, throw: false) + ->get("/repos/{$repositoryPath}/branches", [ + 'limit' => GITEA_PAGE_SIZE, + 'page' => $page, + ]); + + if (! $response->successful()) { + return []; + } + + return $response->json(); +} diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 19049f6426..94d758ef55 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -9,6 +9,7 @@ use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GitlabApp; use App\Models\InstanceSettings; @@ -4915,7 +4916,7 @@ function gitRepositorySlug(?string $gitRepository): string return str($gitRepository)->trim('/')->replaceEnd('.git', '')->toString(); } -function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|null $source = null): array +function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp|GitlabApp|GiteaApp|null $source = null): array { $repository = $gitRepository; $providerInfo = [ @@ -4935,6 +4936,7 @@ function convertGitUrl(string $gitRepository, string $deploymentType, GithubApp| switch ($source->getMorphClass()) { case GithubApp::class: case GitlabApp::class: + case GiteaApp::class: $providerInfo['host'] = Url::fromString($source->html_url)->getHost(); $providerInfo['port'] = $source->custom_port; $providerInfo['user'] = $source->custom_user; diff --git a/database/migrations/2026_10_12_100000_create_gitea_apps_table.php b/database/migrations/2026_10_12_100000_create_gitea_apps_table.php new file mode 100644 index 0000000000..336ea1b3bb --- /dev/null +++ b/database/migrations/2026_10_12_100000_create_gitea_apps_table.php @@ -0,0 +1,43 @@ +id(); + $table->string('uuid')->unique(); + $table->string('name'); + + $table->string('organization')->nullable(); + $table->string('api_url'); + $table->string('html_url'); + $table->integer('custom_port')->default(22); + $table->string('custom_user')->default('git'); + $table->boolean('is_system_wide')->default(false); + $table->boolean('is_public')->default(false); + + $table->longText('client_id')->nullable(); + $table->longText('client_secret')->nullable(); + $table->longText('access_token')->nullable(); + $table->longText('refresh_token')->nullable(); + $table->integer('expires_at')->nullable(); + $table->string('redirect_uri')->nullable(); + $table->longText('webhook_secret')->nullable(); + + $table->foreignId('private_key_id')->nullable(); + $table->foreignId('team_id'); + + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('gitea_apps'); + } +}; diff --git a/resources/views/components/git-icon.blade.php b/resources/views/components/git-icon.blade.php index 47ae8f8bbe..ca7196b52b 100644 --- a/resources/views/components/git-icon.blade.php +++ b/resources/views/components/git-icon.blade.php @@ -15,6 +15,16 @@ +@elseif($git === 'App\Models\GiteaApp') + merge(['class' => 'w-6 h-6']) }} viewBox="0 0 24 24" + stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> + + + + + + + @else merge(['class' => 'w-6 h-6']) }} viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> diff --git a/resources/views/components/top-breadcrumb.blade.php b/resources/views/components/top-breadcrumb.blade.php index 7d18f67b17..6acaab08ae 100644 --- a/resources/views/components/top-breadcrumb.blade.php +++ b/resources/views/components/top-breadcrumb.blade.php @@ -65,7 +65,8 @@ $currentStorage = $storages->firstWhere('uuid', $storageUuid); $githubAppUuid = request()->route('github_app_uuid'); $gitlabAppUuid = request()->route('gitlab_app_uuid'); - $sourceUuid = $githubAppUuid ?? $gitlabAppUuid; + $giteaAppUuid = request()->route('gitea_app_uuid'); + $sourceUuid = $githubAppUuid ?? $gitlabAppUuid ?? $giteaAppUuid; $sources = $sourceUuid && $team ? $team->sources()->sortBy('name')->values() : collect(); $currentSource = $sources->firstWhere('uuid', $sourceUuid); $destinationUuid = request()->route('destination_uuid'); @@ -140,9 +141,11 @@ @endphp diff --git a/resources/views/livewire/project/application/source.blade.php b/resources/views/livewire/project/application/source.blade.php index bf1b4bdccc..c2dbc5836b 100644 --- a/resources/views/livewire/project/application/source.blade.php +++ b/resources/views/livewire/project/application/source.blade.php @@ -74,11 +74,14 @@ $currentSourceMorph = $currentSource?->getMorphClass(); $currentSourceIsGithub = $currentSourceMorph === \App\Models\GithubApp::class; $currentSourceIsGitlab = $currentSourceMorph === \App\Models\GitlabApp::class; + $currentSourceIsGitea = $currentSourceMorph === \App\Models\GiteaApp::class; $currentSourceSubtitle = match (true) { $currentSourceIsGithub && filled(data_get($currentSource, 'organization')) => 'GitHub ยท '.$currentSource->organization, $currentSourceIsGithub => data_get($currentSource, 'is_public') ? 'Public GitHub' : 'GitHub ยท Personal account', $currentSourceIsGitlab && filled(data_get($currentSource, 'group_name')) => 'GitLab ยท '.$currentSource->group_name, $currentSourceIsGitlab => 'GitLab ยท Personal account', + $currentSourceIsGitea && filled(data_get($currentSource, 'organization')) => 'Gitea ยท '.$currentSource->organization, + $currentSourceIsGitea => 'Gitea ยท Personal account', default => 'Connected source', }; @endphp @@ -113,11 +116,14 @@ $sourceMorph = $source->getMorphClass(); $sourceIsGithub = $sourceMorph === \App\Models\GithubApp::class; $sourceIsGitlab = $sourceMorph === \App\Models\GitlabApp::class; + $sourceIsGitea = $sourceMorph === \App\Models\GiteaApp::class; $sourceSubtitle = match (true) { $sourceIsGithub && filled($source->organization) => 'GitHub ยท '.$source->organization, $sourceIsGithub => data_get($source, 'is_public') ? 'Public GitHub' : 'GitHub ยท Personal account', $sourceIsGitlab && filled($source->group_name) => 'GitLab ยท '.$source->group_name, $sourceIsGitlab => 'GitLab ยท Personal account', + $sourceIsGitea && filled($source->organization) => 'Gitea ยท '.$source->organization, + $sourceIsGitea => 'Gitea ยท Personal account', default => 'Git source', }; @endphp diff --git a/resources/views/livewire/project/new/gitea-private-repository.blade.php b/resources/views/livewire/project/new/gitea-private-repository.blade.php new file mode 100644 index 0000000000..6ed15d6312 --- /dev/null +++ b/resources/views/livewire/project/new/gitea-private-repository.blade.php @@ -0,0 +1,146 @@ +
+ @if ($gitea_apps->isEmpty()) +
+
+
+

Gitea App

+

Connect a Gitea App before selecting a private repository.

+
+
+
+ + + + + + + +
+
+ @elseif ($current_step === 'gitea_apps') +
+
+
+

Choose Gitea App

+

Select the Gitea App that can access the repository you want to deploy.

+
+ + + +
+
+ @foreach ($gitea_apps as $gtapp) + + @endforeach +
+
+ @elseif ($current_step === 'repository') +
+
+
+

Choose repository

+

Search repositories available through the selected Gitea App.

+
+ @if ($gitea_app_id) + + Refresh + + @endif +
+
+ @if ($repositories->isNotEmpty()) +
+ + + + Load repository + +
+ @else + + @endif +
+
+ + @if ($branches->isNotEmpty()) +
+
+
+
+

Build configuration

+

Choose the branch and build strategy for this application.

+
+ Continue +
+
+
+ + + @if ($show_is_static) + + + @endif + @if ($is_static) + + @endif +
+ + @if ($build_pack === 'dockercompose') + + @else + + @endif +
+
+
+ @endif + @endif +
diff --git a/resources/views/livewire/project/resource/create.blade.php b/resources/views/livewire/project/resource/create.blade.php index 2821dbfc25..ec58c27455 100644 --- a/resources/views/livewire/project/resource/create.blade.php +++ b/resources/views/livewire/project/resource/create.blade.php @@ -8,6 +8,8 @@ @elseif ($type === 'private-gitlab-app') + @elseif ($type === 'private-gitea-app') + @elseif ($type === 'private-deploy-key') @elseif ($type === 'dockerfile') diff --git a/resources/views/livewire/source/gitea/change.blade.php b/resources/views/livewire/source/gitea/change.blade.php new file mode 100644 index 0000000000..a59fb7abf4 --- /dev/null +++ b/resources/views/livewire/source/gitea/change.blade.php @@ -0,0 +1,306 @@ +
+ + {{ $name ?: 'Gitea App' }} | Sources | Coolify + + + @if ($isConnected) +
+
+
+

+ {{ $name ?: 'Gitea App' }} +

+ +
+

+ {{ filled($organization) ? 'Gitea App for '.$organization : 'Private Gitea source' }} +

+
+
+ @can('view', $gitea_app) + + Test connection + + @endcan + @can('delete', $gitea_app) + + @endcan +
+
+ +
+ + + +
+ + + @if (! isCloud()) +
+ +
+ @if ($isSystemWide) +
+ + System-wide Gitea Apps are shared across all teams on this Coolify instance. Prefer team-specific apps when you need repository isolation. + +
+ @endif + @endif +
+
+ + +
+ + + +
+
+ + +
+ + + + +
+ +
+
+
+ + +
+
+ +
+ +
+
+ + +
+ + @if ($applications->isEmpty()) + + @else +
+
+ + +
+
+
+
+
Project
+
Environment
+
Resource
+
Repository
+
+ @foreach ($applications->sortBy('name', SORT_NATURAL) as $application) + @php + $projectName = (string) data_get($application, 'environment.project.name'); + $environmentName = (string) data_get($application, 'environment.name'); + $resourceName = (string) $application->name; + $repoLabel = $application->git_repository.':'.$application->git_branch; + $searchValue = strtolower( + $projectName.' '.$environmentName.' '.$resourceName.' '.$repoLabel, + ); + @endphp + + {{ $projectName }} + {{ $environmentName }} + {{ $resourceName }} + {{ $repoLabel }} + + @endforeach +
+ @endif +
+
+ @else +
+
+

+ {{ $name ?: 'Gitea App' }} +

+

+ Finish connecting this Gitea App before using it as a source +

+
+ @can('delete', $gitea_app) +
+ +
+ @endcan +
+ +
+ +
+ + {{ rtrim($htmlUrl, '/') }}/user/settings/applications + + +
    +
  • + Set Redirect URI to: + {{ $redirectUri }} +
  • +
  • + Keep Confidential client checked +
  • +
+
+
+ +
+ + + Save + + +
+ + + + +
+ + @if (! isCloud() || isDev()) +
+
+ Gitea will redirect back to this Coolify URL. It must match the Redirect URI on your Gitea OAuth2 application exactly. +
+
+ +
+
+ +
+
+ +
+
+ @endif + +
+ +
+ + + + + @if (! isCloud()) +
+ +
+ @endif +
+
+
+
+ + @if ($clientId) + + + Connect to Gitea + + + + @endif +
+ @endif +
diff --git a/resources/views/livewire/source/gitea/create.blade.php b/resources/views/livewire/source/gitea/create.blade.php new file mode 100644 index 0000000000..a605c8dd9d --- /dev/null +++ b/resources/views/livewire/source/gitea/create.blade.php @@ -0,0 +1,59 @@ +@can('createAnyResource') +
+

+ Connect a Gitea OAuth2 application for private repositories, webhooks, and deployments. +

+ +
+ + +
+ + @if (! isCloud()) +
+
+ +
+
+ + System-wide Gitea Apps are available to every team on this instance. Prefer team-specific apps when you need repository isolation. + +
+
+ @endif + +
+ +
+
+ + + + +
+
+
+ + + Continue + +
+@else + + You don't have permission to create new Gitea Apps. Contact your team administrator. + +@endcan diff --git a/resources/views/source/all.blade.php b/resources/views/source/all.blade.php index 2a3ebe330b..528f92d98c 100644 --- a/resources/views/source/all.blade.php +++ b/resources/views/source/all.blade.php @@ -44,6 +44,16 @@ + + + + + + @endcan @@ -56,11 +66,15 @@ @else @php $items = $sources->map(function ($source) { - $isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp'; + $isGitlab = $source->getMorphClass() === 'App\\Models\\GitlabApp'; return [ 'name' => $source->name, - 'provider' => $isGithub ? 'GitHub' : 'GitLab', - 'organization' => $isGithub ? $source->organization : $source->group_name, + 'provider' => match ($source->getMorphClass()) { + 'App\\Models\\GithubApp' => 'GitHub', + 'App\\Models\\GiteaApp' => 'Gitea', + default => 'GitLab', + }, + 'organization' => $isGitlab ? $source->group_name : $source->organization, 'status' => $source->isConnected() ? 'Connected' : 'Setup incomplete', ]; })->values(); @@ -120,6 +134,33 @@ +
+ @if ($source->isConnected()) + + @else + + @endif +
+ + @elseif ($source->getMorphClass() === 'App\Models\GiteaApp') + +
+
+ +
+
+

+ {{ $source->name }} +

+

+ {{ $source->organization ? "Gitea ยท {$source->organization}" : 'Gitea' }} +

+
+
+
@if ($source->isConnected()) @@ -135,10 +176,11 @@
Source
Provider
Status
@foreach ($sources as $source) @php - $isGithub = $source->getMorphClass() === 'App\\Models\\GithubApp'; - $provider = $isGithub ? 'GitHub' : 'GitLab'; - $organization = $isGithub ? $source->organization : $source->group_name; - $href = $isGithub ? route('source.github.show', ['github_app_uuid' => $source->uuid]) : route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]); + [$provider, $organization, $href] = match ($source->getMorphClass()) { + 'App\\Models\\GithubApp' => ['GitHub', $source->organization, route('source.github.show', ['github_app_uuid' => $source->uuid])], + 'App\\Models\\GiteaApp' => ['Gitea', $source->organization, route('source.gitea.show', ['gitea_app_uuid' => $source->uuid])], + default => ['GitLab', $source->group_name, route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid])], + }; @endphp
{{ $source->name }}
diff --git a/routes/web.php b/routes/web.php index 2efe33c643..27e1bfa892 100644 --- a/routes/web.php +++ b/routes/web.php @@ -123,6 +123,7 @@ use App\Livewire\SharedVariables\Project\Show as ProjectSharedVariablesShow; use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex; use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow; use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex; +use App\Livewire\Source\Gitea\Change as GiteaChange; use App\Livewire\Source\Github\Change as GitHubChange; use App\Livewire\Source\Gitlab\Change as GitLabChange; use App\Livewire\Storage\Index as StorageIndex; @@ -548,6 +549,7 @@ Route::middleware(['auth'])->group(function () { Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions'); Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources'); Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show'); + Route::get('/source/gitea/{gitea_app_uuid}', GiteaChange::class)->name('source.gitea.show'); }); Route::middleware(['auth'])->group(function () { diff --git a/routes/webhooks.php b/routes/webhooks.php index cc5524de4f..a0ffa528de 100644 --- a/routes/webhooks.php +++ b/routes/webhooks.php @@ -11,6 +11,7 @@ Route::middleware(['web', 'auth', 'throttle:60,1'])->group(function () { Route::get('/source/github/redirect', [Github::class, 'redirect']); Route::get('/source/github/install', [Github::class, 'install']); Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']); + Route::get('/source/gitea/redirect', [Gitea::class, 'redirect']); }); Route::post('/source/github/events', [Github::class, 'normal']); @@ -21,6 +22,7 @@ Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']); Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']); +Route::post('/source/gitea/events', [Gitea::class, 'normal']); Route::post('/source/gitea/events/manual', [Gitea::class, 'manual']); Route::post('/payments/stripe/events', [Stripe::class, 'events']); diff --git a/tests/Feature/ApplicationSourceCrossTeamTest.php b/tests/Feature/ApplicationSourceCrossTeamTest.php index 5d6fd1c3dc..9dd7633d0f 100644 --- a/tests/Feature/ApplicationSourceCrossTeamTest.php +++ b/tests/Feature/ApplicationSourceCrossTeamTest.php @@ -3,6 +3,7 @@ use App\Livewire\Project\Application\Source; use App\Models\Application; use App\Models\Environment; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\InstanceSettings; use App\Models\PrivateKey; @@ -242,3 +243,47 @@ describe('after the user switches the session team', function () { expect($this->applicationA->refresh()->source_id)->toBe($this->systemWideGithubApp->id); }); }); + +function applicationSourceGiteaAppForTeam(int $teamId, string $name): GiteaApp +{ + // Http::GitSource() pins DNS before Http::fake() answers, so use an IP literal. + return GiteaApp::create([ + 'name' => $name, + 'team_id' => $teamId, + 'api_url' => 'https://1.1.1.1/api/v1', + 'html_url' => 'https://1.1.1.1', + 'access_token' => 'gitea-access-token', + 'refresh_token' => 'gitea-refresh-token', + 'expires_at' => time() + 3600, + 'is_public' => false, + ]); +} + +test('changeSource switches to an owned GiteaApp and stores the Gitea repository id', function () { + Http::fake([ + '1.1.1.1/api/v1/repos/acme/web' => Http::response(['id' => 314, 'full_name' => 'acme/web']), + ]); + $source = applicationSourceGiteaAppForTeam($this->teamA->id, 'own-gitea-app'); + $this->applicationA->update(['git_repository' => 'acme/web']); + + Livewire::test(Source::class, ['application' => $this->applicationA->fresh()]) + ->call('changeSource', $source->id, GiteaApp::class) + ->assertDispatched('configurationChanged'); + + $this->applicationA->refresh(); + expect($this->applicationA->source_type)->toBe(GiteaApp::class) + ->and($this->applicationA->source_id)->toBe($source->id) + ->and($this->applicationA->repository_project_id)->toBe(314); +}); + +test('changeSource rejects a GiteaApp owned by another team', function () { + Http::fake(); + $victimSource = applicationSourceGiteaAppForTeam($this->teamB->id, 'victim-gitea-app'); + + Livewire::test(Source::class, ['application' => $this->applicationA]) + ->call('changeSource', $victimSource->id, GiteaApp::class); + + $this->applicationA->refresh(); + expect($this->applicationA->source_type)->not->toBe(GiteaApp::class); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/GitSourceTypeIsolationTest.php b/tests/Feature/GitSourceTypeIsolationTest.php new file mode 100644 index 0000000000..4cb185973f --- /dev/null +++ b/tests/Feature/GitSourceTypeIsolationTest.php @@ -0,0 +1,96 @@ +team = Team::factory()->create(); + $this->githubApp = GithubApp::create([ + 'name' => 'github-app', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'app_id' => 1234567890, + 'webhook_secret' => 'test-secret', + 'is_public' => false, + 'team_id' => $this->team->id, + ]); + $this->giteaApp = GiteaApp::create([ + 'name' => 'gitea-app', + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'team_id' => $this->team->id, + ]); + // Both sources share the same id, so only source_type tells them apart. + expect($this->giteaApp->id)->toBe($this->githubApp->id); +}); + +function createSourceTypeIsolationApplication(Team $team, GiteaApp $source): Application +{ + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = $server->standaloneDockers()->firstOrFail(); + + return Application::create([ + 'name' => 'gitea-sourced-app', + 'git_repository' => 'test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'repository_project_id' => 987654321, + 'source_id' => $source->id, + 'source_type' => GiteaApp::class, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +} + +it('does not route a GitHub App webhook to an application of another source type with the same id', function () { + Queue::fake(); + createSourceTypeIsolationApplication($this->team, $this->giteaApp); + + $body = json_encode([ + 'action' => 'closed', + 'number' => 42, + 'repository' => ['id' => 987654321, 'full_name' => 'test-org/test-repo'], + 'pull_request' => [ + 'html_url' => 'https://github.com/test-org/test-repo/pull/42', + 'title' => 'Change', + 'author_association' => 'OWNER', + 'head' => ['ref' => 'feature', 'sha' => 'e83c5163316f89bfbde7d9ab23ca2e25604af290'], + 'base' => ['ref' => 'main'], + ], + ], JSON_THROW_ON_ERROR); + + $response = $this->call('POST', '/webhooks/source/github/events', [], [], [], [ + 'HTTP_X-GitHub-Event' => 'pull_request', + 'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234567890', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'test-secret'), + 'CONTENT_TYPE' => 'application/json', + ], $body); + + $response->assertOk(); + expect($response->getContent())->toContain('Nothing to do'); + Queue::assertNotPushed(ProcessGithubPullRequestWebhook::class); +}); + +it('does not block GitHub App deletion because of an application of another source type with the same id', function () { + createSourceTypeIsolationApplication($this->team, $this->giteaApp); + + $this->githubApp->delete(); + + expect(GithubApp::find($this->githubApp->id))->toBeNull(); +}); diff --git a/tests/Feature/GiteaOAuthCallbackStateTest.php b/tests/Feature/GiteaOAuthCallbackStateTest.php new file mode 100644 index 0000000000..d694653ab1 --- /dev/null +++ b/tests/Feature/GiteaOAuthCallbackStateTest.php @@ -0,0 +1,153 @@ + Http::withOptions(SafeExternalUrl::httpClientOptions( + $url, + resolver: fn (string $host): array => ['93.184.216.34'], + ))); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->giteaApp = GiteaApp::create([ + 'name' => 'Self-hosted Gitea', + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitea/redirect', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +function putGiteaOAuthState(string $state, int $giteaAppId, int $teamId): string +{ + $key = GiteaSource::oauthStateCacheKey($state); + Cache::put($key, ['gitea_app_id' => $giteaAppId, 'team_id' => $teamId], now()->addMinutes(60)); + + return $key; +} + +describe('Gitea OAuth callback state validation', function () { + test('rejects a callback without a state', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any')->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback whose state is the source UUID', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any&state='.$this->giteaApp->uuid) + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects an unknown or expired state', function () { + Http::fake(); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=not-a-real-state') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a state issued for a different team', function () { + Http::fake(); + $otherTeam = Team::factory()->create(); + putGiteaOAuthState('state-for-other-team', $this->giteaApp->id, $otherTeam->id); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=state-for-other-team') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('rejects a callback from a team member who cannot administer the source', function () { + Http::fake(); + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + putGiteaOAuthState('member-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=member-state') + ->assertRedirect(route('source.all')); + + Http::assertNothingSent(); + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); + + test('requires authentication', function () { + auth()->logout(); + session()->forget('currentTeam'); + + $this->get('/webhooks/source/gitea/redirect?code=any&state=any')->assertRedirect(route('login')); + }); + + test('exchanges the code at the Gitea token endpoint, stores the tokens, and consumes the state', function () { + Http::fake([ + 'gitea.example.com/login/oauth/access_token' => Http::response([ + 'access_token' => 'new-access-token', + 'refresh_token' => 'new-refresh-token', + 'token_type' => 'bearer', + ]), + ]); + $key = putGiteaOAuthState('a-valid-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=valid-code&state=a-valid-state') + ->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid])); + + Http::assertSent(fn (HttpRequest $request): bool => $request->url() === 'https://gitea.example.com/login/oauth/access_token' + && $request['grant_type'] === 'authorization_code' + && $request['code'] === 'valid-code' + && $request['client_id'] === 'client-id' + && $request['client_secret'] === 'client-secret' + && $request['redirect_uri'] === 'https://coolify.example.com/webhooks/source/gitea/redirect'); + + $fresh = $this->giteaApp->refresh(); + expect($fresh->access_token)->toBe('new-access-token') + ->and($fresh->refresh_token)->toBe('new-refresh-token') + ->and($fresh->expires_at)->toBeGreaterThanOrEqual(time() + 3590) + ->and($fresh->expires_at)->toBeLessThanOrEqual(time() + 3600) + ->and(Cache::get($key))->toBeNull(); + }); + + test('does not store tokens when the token exchange fails', function () { + Http::fake([ + 'gitea.example.com/login/oauth/access_token' => Http::response(['error_description' => 'bad code'], 400), + ]); + putGiteaOAuthState('failing-state', $this->giteaApp->id, $this->team->id); + + $this->get('/webhooks/source/gitea/redirect?code=bad&state=failing-state') + ->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid])) + ->assertSessionHas('error', 'Gitea OAuth failed: bad code'); + + expect($this->giteaApp->refresh()->access_token)->toBeNull(); + }); +}); diff --git a/tests/Feature/GiteaPrivateRepositoryTest.php b/tests/Feature/GiteaPrivateRepositoryTest.php new file mode 100644 index 0000000000..8eb893f5e0 --- /dev/null +++ b/tests/Feature/GiteaPrivateRepositoryTest.php @@ -0,0 +1,127 @@ + $name, + 'api_url' => giteaPickerBaseUrl().'/api/v1', + 'html_url' => giteaPickerBaseUrl(), + 'client_id' => 'client-id', + 'client_secret' => 'client-secret', + 'access_token' => 'gitea-access-token', + 'refresh_token' => 'gitea-refresh-token', + 'expires_at' => time() + 3600, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +} + +beforeEach(function () { + Queue::fake(); + Server::flushIdentityMap(); + InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->user->teams()->attach($this->team, ['role' => 'owner']); + + $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]); + $this->destination = StandaloneDocker::factory()->create([ + 'server_id' => $this->server->id, + 'name' => 'dest-gitea', + 'network' => 'coolify-gitea', + ]); + + $this->giteaApp = giteaPickerSourceForTeam($this->team, 'Own Gitea'); + + $this->otherTeam = Team::factory()->create(); + $this->otherGiteaApp = giteaPickerSourceForTeam($this->otherTeam, 'Foreign Gitea'); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +it('lists only the current team Gitea Apps', function () { + Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->assertSee('Own Gitea') + ->assertDontSee('Foreign Gitea'); +}); + +it('loads Gitea repositories and branches and creates an application from the selection', function () { + Http::fake([ + '1.1.1.1/api/v1/user/repos*' => Http::response([ + ['id' => 42, 'name' => 'web', 'full_name' => 'acme/web', 'default_branch' => 'develop', 'html_url' => giteaPickerBaseUrl().'/acme/web', 'owner' => ['login' => 'acme']], + ], 200), + '1.1.1.1/api/v1/repos/acme/web/branches*' => Http::response([ + ['name' => 'main'], + ['name' => 'develop'], + ], 200), + ]); + + Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->set('parameters', ['project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid]) + ->set('query', ['destination' => $this->destination->uuid]) + ->call('loadRepositories', $this->giteaApp->id) + ->assertSet('current_step', 'repository') + ->assertSet('selected_project_id', 42) + ->assertSet('selected_repository_path', 'acme/web') + ->call('loadBranches') + ->assertSet('selected_branch_name', 'develop') + ->call('submit') + ->assertHasNoErrors(); + + $application = Application::query()->sole(); + expect($application->repository_project_id)->toBe(42) + ->and($application->git_repository)->toBe('acme/web') + ->and($application->git_branch)->toBe('develop') + ->and($application->source_type)->toBe(GiteaApp::class) + ->and($application->source_id)->toBe($this->giteaApp->id); +}); + +it('rejects another team Gitea App without calling its API', function () { + Http::fake(); + + expect(fn () => Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->call('loadRepositories', $this->otherGiteaApp->id)) + ->toThrow(ModelNotFoundException::class); + + Http::assertNothingSent(); + expect(Application::count())->toBe(0); +}); + +it('locks the selected Gitea App so the client cannot switch it before submit', function () { + expect(fn () => Livewire::test(GiteaPrivateRepository::class, ['type' => 'private-gitea-app']) + ->set('gitea_app_id', $this->otherGiteaApp->id)) + ->toThrow(CannotUpdateLockedPropertyException::class); +}); diff --git a/tests/Feature/GiteaSourceChangeViewTest.php b/tests/Feature/GiteaSourceChangeViewTest.php new file mode 100644 index 0000000000..0fc92ea783 --- /dev/null +++ b/tests/Feature/GiteaSourceChangeViewTest.php @@ -0,0 +1,214 @@ + 'My Gitea', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'custom_user' => 'git', + 'custom_port' => 22, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ], $attributes)); +} + +beforeEach(function () { + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => null, + 'public_ipv4' => null, + 'public_ipv6' => null, + ]); + + $this->giteaApp = makeGiteaChangeViewApp($this->team); +}); + +describe('Gitea source setup view', function () { + test('shows incomplete-setup guidance with the credential form', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSee('Finish connecting this Gitea App before using it as a source') + ->assertSee('https://gitea.com/user/settings/applications') + ->assertSee('Client ID') + ->assertSee('Client secret') + ->assertSee('Save') + ->assertDontSee('Test connection'); + }); + + test('derives api url when gitea url changes', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('htmlUrl', 'https://gitea.example.com') + ->assertSet('apiUrl', 'https://gitea.example.com/api/v1'); + }); + + test('saves and reloads credentials and organization', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('clientId', 'gitea-client-id') + ->set('clientSecretInput', 'super-secret-value') + ->set('organization', 'myorg') + ->call('submit') + ->assertDispatched('success'); + + $this->giteaApp->refresh(); + expect($this->giteaApp->client_secret)->toBe('super-secret-value') + ->and($this->giteaApp->organization)->toBe('myorg'); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSet('clientId', 'gitea-client-id') + ->assertSet('clientSecretInput', 'super-secret-value') + ->assertSet('organization', 'myorg'); + }); + + test('uses the gitea redirect path for a custom public endpoint', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->set('use_custom_webhook_endpoint', true) + ->set('custom_webhook_endpoint', 'http://100.75.155.70:8000') + ->assertSet('redirectUri', 'http://100.75.155.70:8000/webhooks/source/gitea/redirect'); + + expect($this->giteaApp->refresh()->redirect_uri) + ->toBe('http://100.75.155.70:8000/webhooks/source/gitea/redirect'); + }); + + test('builds the gitea oauth url and caches the state payload', function () { + $this->giteaApp->update(['client_id' => 'gitea-client-id']); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class); + + $state = $component->get('oauthState'); + $url = $component->instance()->getOAuthUrl(); + + expect($url)->toStartWith('https://gitea.com/login/oauth/authorize?'); + parse_str(parse_url($url, PHP_URL_QUERY), $query); + expect($query)->toMatchArray([ + 'client_id' => 'gitea-client-id', + 'response_type' => 'code', + 'scope' => 'read:user read:repository read:organization', + 'state' => $state, + ])->and($query['redirect_uri'])->toEndWith('/webhooks/source/gitea/redirect'); + + expect(Change::oauthStateCacheKey($state))->toBe('gitea-app-oauth-state:'.hash('sha256', $state)) + ->and(Cache::get(Change::oauthStateCacheKey($state)))->toBe([ + 'gitea_app_id' => $this->giteaApp->id, + 'team_id' => $this->team->id, + ]); + }); + + test('shows the webhook url and secret for a connected app', function () { + $this->giteaApp->update([ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + 'webhook_secret' => 'gitea-webhook-secret', + ]); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->assertSee('/webhooks/source/gitea/events') + ->assertSet('webhookSecret', 'gitea-webhook-secret') + ->assertSee('Test connection'); + }); + + test('test connection reports the authenticated gitea login', function () { + $this->giteaApp->update([ + 'access_token' => 'access-token', + 'refresh_token' => 'refresh-token', + 'expires_at' => time() + 3600, + ]); + + Http::fake([ + 'gitea.com/api/v1/user' => Http::response(['login' => 'gitea-user'], 200), + ]); + + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->call('testConnection') + ->assertDispatched('success', 'Connection successful! Authenticated as: gitea-user'); + }); + + test('redirects after the gitea app is deleted', function () { + Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class) + ->call('delete') + ->assertRedirect(route('source.all')); + + $this->assertModelMissing($this->giteaApp); + }); +}); + +describe('Gitea source authorization', function () { + test('user of another team cannot open a team gitea source', function () { + $otherTeam = Team::factory()->create(); + $foreign = makeGiteaChangeViewApp($otherTeam, ['client_secret' => 'foreign-secret']); + + Livewire::withQueryParams(['gitea_app_uuid' => $foreign->uuid]) + ->test(Change::class) + ->assertStatus(404); + }); + + test('unrelated users cannot see system-wide source secrets', function () { + $otherTeam = Team::factory()->create(); + $shared = makeGiteaChangeViewApp($otherTeam, [ + 'is_system_wide' => true, + 'client_secret' => 'shared-client-secret', + 'webhook_secret' => 'shared-webhook-secret', + ]); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $shared->uuid]) + ->test(Change::class) + ->assertSet('clientSecretInput', null) + ->assertSet('webhookSecret', null); + + expect($component->html()) + ->not->toContain('shared-client-secret') + ->not->toContain('shared-webhook-secret'); + }); + + test('member of the source team cannot update it after switching to a team they own', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $ownTeam = Team::factory()->create(); + $ownTeam->members()->attach($member->id, ['role' => 'owner']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + $component = Livewire::withQueryParams(['gitea_app_uuid' => $this->giteaApp->uuid]) + ->test(Change::class); + + session(['currentTeam' => $ownTeam]); + + $component->set('name', 'hijacked') + ->call('submit') + ->assertDispatched('error'); + $component->call('delete'); + + $this->assertModelExists($this->giteaApp); + expect($this->giteaApp->refresh()->name)->toBe('My Gitea'); + }); +}); diff --git a/tests/Feature/GiteaSourceCreateModalTest.php b/tests/Feature/GiteaSourceCreateModalTest.php new file mode 100644 index 0000000000..7e411e7883 --- /dev/null +++ b/tests/Feature/GiteaSourceCreateModalTest.php @@ -0,0 +1,65 @@ +team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); +}); + +describe('Gitea source create modal', function () { + test('renders the create form', function () { + Livewire::test(Create::class) + ->assertSee('Self-hosted Gitea') + ->assertSee('Organization') + ->assertSee('Continue'); + }); + + test('creates a gitea app with defaults for gitea.com and redirects to it', function () { + $component = Livewire::test(Create::class) + ->set('name', 'my-gitea') + ->set('organization', 'myorg,myuser') + ->call('createGiteaApp'); + + $app = GiteaApp::where('name', 'my-gitea')->first(); + expect($app)->not->toBeNull() + ->and($app->html_url)->toBe('https://gitea.com') + ->and($app->api_url)->toBe('https://gitea.com/api/v1') + ->and($app->custom_user)->toBe('git') + ->and($app->custom_port)->toBe(22) + ->and($app->organization)->toBe('myorg,myuser') + ->and($app->team_id)->toBe($this->team->id) + ->and(strlen((string) $app->webhook_secret))->toBe(32); + + $component->assertRedirect(route('source.gitea.show', ['gitea_app_uuid' => $app->uuid])); + }); + + test('derives api url when html url changes', function () { + Livewire::test(Create::class) + ->set('html_url', 'https://gitea.example.com') + ->assertSet('api_url', 'https://gitea.example.com/api/v1'); + }); + + test('team member cannot create a gitea app', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + + Livewire::test(Create::class) + ->set('name', 'member-gitea') + ->call('createGiteaApp'); + + expect(GiteaApp::where('name', 'member-gitea')->exists())->toBeFalse(); + }); +}); diff --git a/tests/Feature/GiteaSourceListTest.php b/tests/Feature/GiteaSourceListTest.php new file mode 100644 index 0000000000..879d928095 --- /dev/null +++ b/tests/Feature/GiteaSourceListTest.php @@ -0,0 +1,59 @@ + 0]); + $this->team = Team::factory()->create(['show_boarding' => false]); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + $this->giteaApp = GiteaApp::create([ + 'name' => 'Team Gitea', + 'organization' => 'acme', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +}); + +test('team sources include gitea apps and count them as deletion blockers', function () { + expect($this->team->sources()->pluck('uuid'))->toContain($this->giteaApp->uuid) + ->and($this->team->deletionBlockers())->toMatchArray(['sources' => 1]); +}); + +test('sources page lists gitea apps with a link to their settings', function () { + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->get(route('source.all')) + ->assertOk() + ->assertSee('New Gitea App') + ->assertSee('Team Gitea') + ->assertSee('Gitea ยท acme') + ->assertSee(route('source.gitea.show', ['gitea_app_uuid' => $this->giteaApp->uuid]), false); +}); + +test('deleting a team transfers system-wide gitea apps and removes team gitea apps', function () { + $systemWide = GiteaApp::create([ + 'name' => 'Shared Gitea', + 'api_url' => 'https://gitea.com/api/v1', + 'html_url' => 'https://gitea.com', + 'team_id' => $this->team->id, + 'is_system_wide' => true, + 'is_public' => false, + ]); + + $this->team->delete(); + + expect($systemWide->refresh()->team_id)->toBe(0) + ->and(GiteaApp::find($this->giteaApp->id))->toBeNull(); +}); diff --git a/tests/Feature/Webhook/GiteaAppWebhookTest.php b/tests/Feature/Webhook/GiteaAppWebhookTest.php new file mode 100644 index 0000000000..2e6f5e942c --- /dev/null +++ b/tests/Feature/Webhook/GiteaAppWebhookTest.php @@ -0,0 +1,236 @@ + 0]); + + $this->team = Team::factory()->create(); + $this->giteaApp = giteaAppWebhookSource($this->team, 'gitea-app-secret'); +}); + +function giteaAppWebhookSource(Team $team, string $secret): GiteaApp +{ + return GiteaApp::create([ + 'name' => 'gitea-app-'.str()->random(6), + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'webhook_secret' => $secret, + 'team_id' => $team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); +} + +function giteaAppWebhookApplication(Team $team, int $sourceId, string $sourceType = GiteaApp::class): Application +{ + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]); + $destination = $server->standaloneDockers()->firstOrFail(); + + $application = Application::create([ + 'name' => 'gitea-app-webhook-app', + 'git_repository' => 'test-org/test-repo', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'source_id' => $sourceId, + 'source_type' => $sourceType, + 'repository_project_id' => 555, + ]); + $application->settings->update(['is_auto_deploy_enabled' => true, 'is_preview_deployments_enabled' => true]); + + return $application->refresh(); +} + +/** + * @return array + */ +function giteaAppWebhookPayload(string $event, string $action = 'opened'): array +{ + $sha = 'e83c5163316f89bfbde7d9ab23ca2e25604af290'; + + if ($event === 'push') { + return [ + 'ref' => 'refs/heads/main', + 'before' => 'a83c5163316f89bfbde7d9ab23ca2e25604af290', + 'after' => $sha, + 'repository' => ['id' => 555, 'full_name' => 'test-org/test-repo'], + 'commits' => [['message' => 'Update', 'added' => [], 'modified' => ['README.md'], 'removed' => []]], + ]; + } + + return [ + 'action' => $action, + 'number' => 7, + 'repository' => ['id' => 555, 'full_name' => 'test-org/test-repo'], + 'pull_request' => [ + 'html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/7', + 'title' => 'Add feature', + 'head' => ['ref' => 'feature', 'sha' => $sha, 'repo' => ['id' => 555]], + 'base' => ['ref' => 'main', 'repo' => ['id' => 555]], + ], + ]; +} + +/** + * @param array $headers + */ +function sendGiteaAppWebhook(TestCase $test, string $event, array $payload, ?string $secret = 'gitea-app-secret', array $headers = []): TestResponse +{ + $body = json_encode($payload); + $server = ['CONTENT_TYPE' => 'application/json', 'HTTP_X-Gitea-Event' => $event]; + if ($secret !== null) { + $server['HTTP_X-Gitea-Signature'] = hash_hmac('sha256', $body, $secret); + } + + return $test->call('POST', '/webhooks/source/gitea/events', [], [], [], array_merge($server, $headers), $body); +} + +describe('Gitea App webhook authentication', function () { + test('rejects a delivery without a signature', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + + sendGiteaAppWebhook($this, 'push', giteaAppWebhookPayload('push'), secret: null)->assertStatus(401); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); + + test('rejects a delivery with an invalid signature', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + + sendGiteaAppWebhook($this, 'push', giteaAppWebhookPayload('push'), secret: 'wrong-secret')->assertStatus(401); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); + + test('accepts a GitHub-style X-Hub-Signature-256 header', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + $payload = giteaAppWebhookPayload('push'); + $body = json_encode($payload); + + $this->call('POST', '/webhooks/source/gitea/events', [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_X-Gitea-Event' => 'push', + 'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'gitea-app-secret'), + ], $body)->assertOk(); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeTrue(); + }); + + test('answers ping deliveries', function () { + sendGiteaAppWebhook($this, 'ping', ['hook_id' => 1])->assertOk()->assertSee('pong'); + }); +}); + +describe('Gitea App webhook push', function () { + test('queues a deployment only for applications of the signing Gitea App', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + + $otherGiteaApp = giteaAppWebhookSource(Team::factory()->create(), 'other-secret'); + $otherGiteaApplication = giteaAppWebhookApplication($otherGiteaApp->team, $otherGiteaApp->id); + + // A GitLab App with the same id must not have its applications deployed. + $gitlabApp = GitlabApp::forceCreate([ + 'id' => $this->giteaApp->id, + 'uuid' => (string) str()->uuid(), + 'name' => 'colliding-gitlab-app', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'team_id' => $this->team->id, + 'is_system_wide' => false, + 'is_public' => false, + ]); + $gitlabApplication = giteaAppWebhookApplication($this->team, $gitlabApp->id, GitlabApp::class); + + sendGiteaAppWebhook($this, 'push', giteaAppWebhookPayload('push')) + ->assertOk() + ->assertSee('gitea-app-webhook-app'); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->where('commit', 'e83c5163316f89bfbde7d9ab23ca2e25604af290')->exists())->toBeTrue() + ->and(ApplicationDeploymentQueue::query()->where('application_id', $otherGiteaApplication->id)->exists())->toBeFalse() + ->and(ApplicationDeploymentQueue::query()->where('application_id', $gitlabApplication->id)->exists())->toBeFalse(); + }); + + test('does not deploy a push to another branch', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + $payload = giteaAppWebhookPayload('push'); + $payload['ref'] = 'refs/heads/develop'; + + sendGiteaAppWebhook($this, 'push', $payload)->assertOk(); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); + + test('skips commits marked with [skip ci]', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + $payload = giteaAppWebhookPayload('push'); + $payload['commits'][0]['message'] = 'Docs [skip ci]'; + + sendGiteaAppWebhook($this, 'push', $payload)->assertOk()->assertSee('skipped'); + + expect(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); +}); + +describe('Gitea App webhook pull requests', function () { + test('an opened pull request creates a preview and queues a preview deployment', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + + sendGiteaAppWebhook($this, 'pull_request', giteaAppWebhookPayload('pull_request', 'opened'))->assertOk(); + + $preview = ApplicationPreview::query()->where('application_id', $application->id)->where('pull_request_id', 7)->first(); + expect($preview)->not->toBeNull() + ->and($preview->git_type)->toBe('gitea') + ->and(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->where('pull_request_id', 7)->exists())->toBeTrue(); + }); + + test('a pull request from a fork is not deployed without public previews', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + $payload = giteaAppWebhookPayload('pull_request', 'opened'); + $payload['pull_request']['head']['repo']['id'] = 999; + + sendGiteaAppWebhook($this, 'pull_request', $payload)->assertOk(); + + expect(ApplicationPreview::query()->where('application_id', $application->id)->exists())->toBeFalse() + ->and(ApplicationDeploymentQueue::query()->where('application_id', $application->id)->exists())->toBeFalse(); + }); + + test('a closed pull request cleans up the preview deployment', function () { + $application = giteaAppWebhookApplication($this->team, $this->giteaApp->id); + ApplicationPreview::create([ + 'git_type' => 'gitea', + 'application_id' => $application->id, + 'pull_request_id' => 7, + 'pull_request_html_url' => 'https://gitea.example.com/test-org/test-repo/pulls/7', + ]); + CleanupPreviewDeployment::shouldRun()->once(); + + sendGiteaAppWebhook($this, 'pull_request', giteaAppWebhookPayload('pull_request', 'closed')) + ->assertOk() + ->assertSee('Preview deployment closed'); + }); +}); diff --git a/tests/Unit/GiteaSourceCommandsTest.php b/tests/Unit/GiteaSourceCommandsTest.php new file mode 100644 index 0000000000..ef741bb92d --- /dev/null +++ b/tests/Unit/GiteaSourceCommandsTest.php @@ -0,0 +1,170 @@ +map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command)->implode(' && '); +} + +/** + * A non-expired token short-circuits refreshGiteaToken(), so no HTTP call is made. + */ +function giteaConnectedSource(string $htmlUrl = 'https://gitea.example.test'): GiteaApp +{ + $source = new GiteaApp; + $source->forceFill([ + 'html_url' => $htmlUrl, + 'api_url' => $htmlUrl.'/api/v1', + 'is_public' => false, + 'custom_port' => 22, + 'custom_user' => 'git', + 'team_id' => 1, + ]); + $source->access_token = 'gt-token/with+sym'; + $source->refresh_token = 'gt-refresh-token'; + $source->expires_at = time() + 3600; + + return $source; +} + +function giteaApplicationFor(GiteaApp $source, string $repository = 'owner/private-app', bool $submodules = false): Application +{ + $application = new Application; + $application->forceFill([ + 'uuid' => 'test-app-uuid', + 'git_repository' => $repository, + 'git_branch' => 'main', + 'git_commit_sha' => 'HEAD', + ]); + + $settings = new ApplicationSetting; + $settings->is_git_shallow_clone_enabled = false; + $settings->is_git_submodules_enabled = $submodules; + $settings->is_git_lfs_enabled = false; + $application->setRelation('settings', $settings); + $application->setRelation('source', $source); + + return $application; +} + +it('clones a connected Gitea source over HTTPS with the OAuth token and per-command submodule credentials', function () { + $application = giteaApplicationFor(giteaConnectedSource(), submodules: true); + + $result = $application->generateGitImportCommands(deployment_uuid: 'test-deployment', exec_in_docker: false); + + $expectedConfig = "git -c 'url.https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/.insteadOf=https://gitea.example.test/'"; + + expect($result['fullRepoUrl'])->toBe('https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/owner/private-app.git'); + expect($result['commands']) + ->not->toContain('git config --global') + ->toContain("{$expectedConfig} clone --recurse-submodules -b 'main' 'https://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test/owner/private-app.git'") + ->toContain("{$expectedConfig} submodule update --init --recursive"); +}); + +it('fetches the Gitea pull request ref through the OAuth git config', function () { + $application = giteaApplicationFor(giteaConnectedSource(), submodules: true); + + $result = $application->generateGitImportCommands( + deployment_uuid: 'test-deployment', + pull_request_id: 7, + exec_in_docker: false, + only_checkout: true, + custom_base_dir: '/artifacts/test', + ); + + expect($result['branch'])->toBe('pull/7/head:pr-7-coolify'); + expect($result['commands']) + ->toContain(".insteadOf=https://gitea.example.test/' fetch origin 'pull/7/head:pr-7-coolify'") + ->not->toContain('merge-requests'); +}); + +it('runs ls-remote for a connected Gitea source with the OAuth token and custom port', function () { + $application = giteaApplicationFor(giteaConnectedSource('http://gitea.example.test:3000')); + + $result = $application->generateGitLsRemoteCommands('test-deployment', false); + + expect($result['fullRepoUrl'])->toBe('http://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test:3000/owner/private-app.git'); + expect($result['commands'])->toContain("git ls-remote 'http://oauth2:gt-token%2Fwith%2Bsym@gitea.example.test:3000/owner/private-app.git'"); +}); + +it('falls back to the Gitea source private key only when it belongs to the source team', function (int $keyTeamId, bool $expectKey) { + $privateKey = new PrivateKey; + $privateKey->forceFill(['team_id' => $keyTeamId, 'private_key' => 'fake-private-key']); + + $source = new GiteaApp; + $source->forceFill(['html_url' => 'https://gitea.example.test', 'custom_port' => 2222, 'team_id' => 1]); + $source->setRelation('privateKey', $privateKey); + + $application = giteaApplicationFor($source, 'git@gitea.example.test:owner/private-app.git'); + + $lsRemote = giteaCommandsAsString($application->generateGitLsRemoteCommands('test-deployment', false)['commands']); + $import = $application->generateGitImportCommands(deployment_uuid: 'test-deployment', pull_request_id: 3, exec_in_docker: false); + $importCommands = giteaCommandsAsString($import['commands']); + + foreach ([$lsRemote, $importCommands] as $commands) { + expect($commands)->not->toContain('oauth2:'); + $expectKey + ? expect($commands)->toContain('id_rsa_coolify_')->toContain('-p 2222') + : expect($commands)->not->toContain('id_rsa_coolify_'); + } + if ($expectKey) { + expect($import['branch'])->toBe('pull/3/head:pr-3-coolify'); + } +})->with([ + 'same team' => [1, true], + 'other team' => [2, false], +]); + +it('clones a disconnected Gitea source without a key as a public repository', function () { + $source = new GiteaApp; + $source->forceFill(['html_url' => 'https://gitea.example.test', 'team_id' => 1]); + $source->setRelation('privateKey', null); + + $application = giteaApplicationFor($source, 'https://gitea.example.test/owner/public-app.git'); + + $result = $application->generateGitImportCommands(deployment_uuid: 'test-deployment', exec_in_docker: false); + + expect($result['fullRepoUrl'])->toBe('https://gitea.example.test/owner/public-app.git'); + expect(giteaCommandsAsString($result['commands'])) + ->toContain("'https://gitea.example.test/owner/public-app.git'") + ->not->toContain('oauth2:') + ->not->toContain('id_rsa_coolify_'); +}); + +it('links Gitea branches and commits with Gitea URL paths', function () { + $application = giteaApplicationFor(giteaConnectedSource()); + $application->base_directory = '/app'; + + expect($application->git_branch_location)->toBe('https://gitea.example.test/owner/private-app/src/branch/main/app'); + expect($application->git_commits)->toBe('https://gitea.example.test/owner/private-app/commits/branch/main'); + expect($application->gitCommitLink('abc123'))->toBe('https://gitea.example.test/owner/private-app/commit/abc123'); +}); + +it('converts a Gitea HTTP repository to SSH for deploy keys', function () { + $source = new GiteaApp; + $source->forceFill(['html_url' => 'https://gitea.example.test', 'custom_port' => 2222, 'custom_user' => 'git']); + + expect(convertGitUrl('owner/repo', 'deploy_key', $source))->toBe([ + 'repository' => 'ssh://git@gitea.example.test:2222/owner/repo', + 'port' => '2222', + ]); +}); diff --git a/tests/Unit/Policies/GiteaAppPolicyTest.php b/tests/Unit/Policies/GiteaAppPolicyTest.php new file mode 100644 index 0000000000..c1b847fbf7 --- /dev/null +++ b/tests/Unit/Policies/GiteaAppPolicyTest.php @@ -0,0 +1,192 @@ +makePartial(); + + $policy = new GiteaAppPolicy; + expect($policy->viewAny($user))->toBeTrue(); +}); + +it('allows any user to view system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGiteaApp(teamId: 1, isSystemWide: true); + + $policy = new GiteaAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('allows team member to view non-system-wide gitea app', function () { + $teams = collect([ + (object) ['id' => 1, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->view($user, $model))->toBeTrue(); +}); + +it('denies non-team member to view non-system-wide gitea app', function () { + $teams = collect([ + (object) ['id' => 2, 'pivot' => (object) ['role' => 'member']], + ]); + + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('getAttribute')->with('teams')->andReturn($teams); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->view($user, $model))->toBeFalse(); +}); + +it('allows admin to create gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(true); + + $policy = new GiteaAppPolicy; + expect($policy->create($user))->toBeTrue(); +}); + +it('denies non-admin to create gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdmin')->andReturn(false); + + $policy = new GiteaAppPolicy; + expect($policy->create($user))->toBeFalse(); +}); + +it('allows user with system access to update system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = mockGiteaApp(teamId: 1, isSystemWide: true); + + $policy = new GiteaAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies user without system access to update system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = mockGiteaApp(teamId: 1, isSystemWide: true); + + $policy = new GiteaAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows team admin to update non-system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->update($user, $model))->toBeTrue(); +}); + +it('denies team member to update non-system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('allows user with system access to delete system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(true); + + $model = mockGiteaApp(teamId: 1, isSystemWide: true); + + $policy = new GiteaAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies user without system access to delete system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('canAccessSystemResources')->andReturn(false); + + $model = mockGiteaApp(teamId: 1, isSystemWide: true); + + $policy = new GiteaAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('allows team admin to delete non-system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->delete($user, $model))->toBeTrue(); +}); + +it('denies team member to delete non-system-wide gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies update when team_id is null without type error', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldNotReceive('isAdminOfTeam'); + + $model = mockGiteaApp(teamId: null, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->update($user, $model))->toBeFalse(); +}); + +it('denies delete when team_id is null without type error', function () { + $user = Mockery::mock(User::class)->makePartial(); + $user->shouldNotReceive('isAdminOfTeam'); + + $model = mockGiteaApp(teamId: null, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->delete($user, $model))->toBeFalse(); +}); + +it('denies restore of gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->restore($user, $model))->toBeFalse(); +}); + +it('denies force delete of gitea app', function () { + $user = Mockery::mock(User::class)->makePartial(); + + $model = mockGiteaApp(teamId: 1, isSystemWide: false); + + $policy = new GiteaAppPolicy; + expect($policy->forceDelete($user, $model))->toBeFalse(); +}); + +function mockGiteaApp(?int $teamId, bool $isSystemWide): GiteaApp +{ + $giteaApp = Mockery::mock(GiteaApp::class)->makePartial(); + $giteaApp->team_id = $teamId; + $giteaApp->is_system_wide = $isSystemWide; + + return $giteaApp; +} diff --git a/tests/Unit/ServerTransfer/ServerTransferCoverageTest.php b/tests/Unit/ServerTransfer/ServerTransferCoverageTest.php index f7782f4d91..82448a4b6b 100644 --- a/tests/Unit/ServerTransfer/ServerTransferCoverageTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferCoverageTest.php @@ -7,6 +7,7 @@ use App\Models\CloudProviderToken; use App\Models\DnsProviderZone; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GitlabApp; @@ -131,6 +132,8 @@ beforeEach(function () { $gitlabApp = transferCoverageRecord(GitlabApp::class, [...$teamId, 'is_system_wide' => false]); $application = Application::factory()->create([...$onDestination, 'source_type' => GithubApp::class, 'source_id' => $githubApp->id, 'private_key_id' => $deployKey->id]); Application::factory()->create([...$onDestination, 'source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id]); + $giteaApp = transferCoverageRecord(GiteaApp::class, [...$teamId, 'is_system_wide' => false]); + Application::factory()->create([...$onDestination, 'source_type' => GiteaApp::class, 'source_id' => $giteaApp->id]); $preview = transferCoverageRecord(ApplicationPreview::class, ['application_id' => $application->id]); $s3 = transferCoverageRecord(S3Storage::class, $teamId); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php index 2160f1f131..2e9610d31f 100644 --- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -5,6 +5,7 @@ use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\Environment; use App\Models\EnvironmentVariable; +use App\Models\GiteaApp; use App\Models\GithubApp; use App\Models\GithubRunnerConfig; use App\Models\GitlabApp; @@ -1001,6 +1002,53 @@ test('system-wide gitlab apps are not exported and re-link on import by uuid', f ->and(GitlabApp::where('is_system_wide', true)->where('uuid', 'system-gitlab-public')->count())->toBe(1); }); +test('team gitea apps round-trip and the application re-links to the imported app', function () { + $giteaApp = new GiteaApp; + $giteaApp->forceFill([ + 'name' => 'Team Gitea App', + 'api_url' => 'https://gitea.example.com/api/v1', + 'html_url' => 'https://gitea.example.com', + 'is_public' => false, + 'is_system_wide' => false, + 'team_id' => $this->team->id, + 'client_id' => 'gitea-client', + 'client_secret' => 'gitea-secret', + 'webhook_secret' => 'gitea-webhook-secret', + ]); + $giteaApp->uuid = 'team-gitea-app'; + $giteaApp->save(); + + $this->application->source_type = GiteaApp::class; + $this->application->source_id = $giteaApp->id; + $this->application->save(); + + $bundle = $this->exporter->export($this->server); + + expect($bundle['gitea_apps'])->toHaveCount(1) + ->and($bundle['gitea_apps'][0]['client_secret'])->toBe('gitea-secret') + ->and(collect($bundle['post_transfer_checklist'])->implode(' '))->toContain('{target_url}/webhooks/source/gitea/redirect'); + + $this->service->forceDelete(); + Application::query()->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + $giteaApp->delete(); + + $targetTeam = Team::factory()->create(); + $result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true); + + $imported = GiteaApp::where('uuid', 'team-gitea-app')->first(); + $application = Application::where('name', 'my-app')->first(); + + expect($result['created']['gitea_apps'])->toBe(1) + ->and($imported->team_id)->toBe($targetTeam->id) + ->and($imported->client_secret)->toBe('gitea-secret') + ->and($imported->webhook_secret)->toBe('gitea-webhook-secret') + ->and($application->source_type)->toBe(GiteaApp::class) + ->and($application->source_id)->toBe($imported->id); +}); + test('import rejects unsafe host file paths before saving storage', function () { $importFileStorages = new ReflectionMethod(ServerTransferImporter::class, 'importFileStorages'); $storage = [