feat(gitea): add Gitea App sources and private repo deployments (#12234)

This commit is contained in:
Andras Bacsai
2026-10-19 21:58:30 +02:00
committed by GitHub
parent dd456625ec
commit cf71fda5c1
50 changed files with 3727 additions and 52 deletions
+1
View File
@@ -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';
@@ -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,
};
}
+364 -6
View File
@@ -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')]),
];
}
}
+1
View File
@@ -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();
+2 -1
View File
@@ -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;
+12 -1
View File
@@ -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',
+9 -2
View File
@@ -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) {
@@ -0,0 +1,245 @@
<?php
namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GiteaApp;
use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
use Livewire\Component;
class GiteaPrivateRepository extends Component
{
use AuthorizesRequests;
public $current_step = 'gitea_apps';
public $gitea_apps;
#[Locked]
public ?int $gitea_app_id = null;
public $parameters;
public $currentRoute;
public $query;
public $type;
public int $selected_project_id;
#[Locked]
public int $selected_gitea_app_id;
public string $selected_repository_path = '';
public string $selected_branch_name = 'main';
public $repositories;
public int $total_repositories_count = 0;
public $branches;
public int $total_branches_count = 0;
public int $port = 3000;
public bool $is_static = false;
public ?string $publish_directory = null;
public ?string $base_directory = '/';
public ?string $docker_compose_location = '/docker-compose.yaml';
protected int $page = 1;
public $build_pack = 'railpack';
public bool $show_is_static = true;
private function getGiteaApp(): GiteaApp
{
return GiteaApp::private()->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!');
}
}
@@ -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),
);
}
@@ -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,
);
}
+6
View File
@@ -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 = [
[
+363
View File
@@ -0,0 +1,363 @@
<?php
namespace App\Livewire\Source\Gitea;
use App\Models\GiteaApp;
use App\Models\PrivateKey;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Component;
class Change extends Component
{
use AuthorizesRequests;
public string $webhook_endpoint = '';
public string $custom_webhook_endpoint = '';
public bool $use_custom_webhook_endpoint = false;
public ?string $ipv4 = null;
public ?string $ipv6 = null;
public ?string $fqdn = null;
public $parameters;
public ?GiteaApp $gitea_app = null;
public string $name;
public string $apiUrl;
public string $htmlUrl;
public string $customUser;
public int $customPort;
public ?string $clientId = null;
public ?string $clientSecretInput = null;
public ?string $webhookSecret = null;
public ?string $organization = null;
public bool $isSystemWide;
public ?int $privateKeyId = null;
public $applications;
public $privateKeys;
public bool $isConnected = false;
public ?string $redirectUri = null;
public ?string $oauthState = null;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
protected function rules(): array
{
return [
'name' => '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}";
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace App\Livewire\Source\Gitea;
use App\Models\GiteaApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
public string $name;
public string $html_url = 'https://gitea.com';
public string $api_url = 'https://gitea.com/api/v1';
public string $custom_user = 'git';
public int $custom_port = 22;
public bool $is_system_wide = false;
public ?string $organization = null;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount()
{
$this->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';
}
}
+24 -14
View File
@@ -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 {
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace App\Models;
use App\Traits\Auditable;
class GiteaApp extends BaseModel
{
use Auditable;
protected $fillable = [
'name',
'organization',
'api_url',
'html_url',
'custom_port',
'custom_user',
'is_system_wide',
'is_public',
'client_id',
'client_secret',
'access_token',
'refresh_token',
'expires_at',
'redirect_uri',
'webhook_secret',
'private_key_id',
'team_id',
];
protected $hidden = [
'client_secret',
'access_token',
'refresh_token',
'webhook_secret',
];
protected function casts(): array
{
return [
'access_token' => '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;
}
}
+3 -2
View File
@@ -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();
+7 -1
View File
@@ -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()
+14 -4
View File
@@ -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()
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Policies;
use App\Models\GiteaApp;
use App\Models\User;
class GiteaAppPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, GiteaApp $giteaApp): bool
{
if ($giteaApp->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;
}
}
+8
View File
@@ -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;
});
}
}
+3
View File
@@ -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,
@@ -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,
@@ -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<int, PrivateKey>,
* github_apps: Collection<int, GithubApp>,
* gitlab_apps: Collection<int, GitlabApp>,
* gitea_apps: Collection<int, GiteaApp>,
* s3_storages: Collection<int, S3Storage>,
* cloud_provider_tokens: Collection<int, CloudProviderToken>
* }
@@ -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<GithubApp|GitlabApp> $modelClass
* @param class-string<GithubApp|GitlabApp|GiteaApp> $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<int, GithubApp>, gitlab_apps: Collection<int, GitlabApp>} $dependencies
* @param array{github_apps: Collection<int, GithubApp>, gitlab_apps: Collection<int, GitlabApp>, gitea_apps: Collection<int, GiteaApp>} $dependencies
* @param Collection<int, Application> $applications
* @return list<string>
*/
@@ -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<string, mixed>
*/
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<string, mixed>
*/
@@ -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<string, GitlabApp> */
private array $gitlabAppMap = [];
/** @var array<string, GiteaApp> */
private array $giteaAppMap = [];
/** @var array<string, S3Storage> */
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<string, mixed> $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<string, mixed> $payload
*/
+2 -1
View File
@@ -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,
+163
View File
@@ -0,0 +1,163 @@
<?php
use App\Models\GiteaApp;
use App\Models\PrivateKey;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Gitea caps page sizes at its MAX_RESPONSE_ITEMS setting (default 50).
*/
const GITEA_PAGE_SIZE = 50;
function refreshGiteaToken(GiteaApp $source): void
{
if (! $source->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();
}
+3 -1
View File
@@ -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;
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('gitea_apps', function (Blueprint $table) {
$table->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');
}
};
@@ -15,6 +15,16 @@
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z" />
</svg>
@elseif($git === 'App\Models\GiteaApp')
<svg xmlns="http://www.w3.org/2000/svg" {{ $attributes->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">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 7h14v5a5 5 0 0 1 -5 5h-4a5 5 0 0 1 -5 -5z" />
<path d="M17 9h1.5a2.5 2.5 0 0 1 0 5h-1.5" />
<path d="M9 9.5v4" />
<path d="M9 11.5l2.5 -1.5" />
<path d="M5 20h10" />
</svg>
@else
<svg xmlns="http://www.w3.org/2000/svg" {{ $attributes->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">
@@ -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
<x-breadcrumb-switcher title="Sources" :label="$currentSource->name ?: 'Source'" :items="$sources->map(fn ($source) => [
'label' => $source->name ?: class_basename($source),
'href' => $source instanceof \App\Models\GithubApp
? route('source.github.show', ['github_app_uuid' => $source->uuid])
: route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]),
'href' => match (true) {
$source instanceof \App\Models\GithubApp => route('source.github.show', ['github_app_uuid' => $source->uuid]),
$source instanceof \App\Models\GiteaApp => route('source.gitea.show', ['gitea_app_uuid' => $source->uuid]),
default => route('source.gitlab.show', ['gitlab_app_uuid' => $source->uuid]),
},
'active' => $source->getMorphClass() === $currentSource->getMorphClass() && $source->uuid === $currentSource->uuid,
])">
<x-slot:meta>
@@ -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
@@ -0,0 +1,146 @@
<div class="mt-8 flex w-full max-w-none flex-col gap-6 lg:mt-3">
@if ($gitea_apps->isEmpty())
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Gitea App</h2>
<p>Connect a Gitea App before selecting a private repository.</p>
</div>
</div>
<div class="application-settings-section-body">
<x-empty title="No Gitea Apps"
description="Create an app to grant Coolify access to selected repositories."
icon-name="sources">
<x-slot:contents>
<x-modal-input buttonTitle="+ Add Gitea App" title="New Gitea App" closeOutside="false">
<livewire:source.gitea.create />
</x-modal-input>
</x-slot:contents>
</x-empty>
</div>
</section>
@elseif ($current_step === 'gitea_apps')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Choose Gitea App</h2>
<p>Select the Gitea App that can access the repository you want to deploy.</p>
</div>
<x-modal-input buttonTitle="+ Add Gitea App" title="New Gitea App" closeOutside="false">
<livewire:source.gitea.create />
</x-modal-input>
</div>
<div class="application-settings-section-body p-0!">
@foreach ($gitea_apps as $gtapp)
<button type="button"
class="group flex w-full items-center gap-3 border-b border-neutral-200 px-4 py-3 text-left transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.06] dark:hover:bg-white/[0.025]"
wire:click.prevent="loadRepositories({{ $gtapp->id }})"
wire:loading.attr="disabled" wire:target="loadRepositories({{ $gtapp->id }})"
wire:key="{{ $gtapp->id }}">
<div
class="flex size-9 shrink-0 items-center justify-center rounded-lg bg-neutral-100 text-neutral-500 dark:bg-white/[0.06] dark:text-fg-dim">
<x-git-icon class="size-4" git="App\Models\GiteaApp" />
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold text-black dark:text-fg">
{{ data_get($gtapp, 'name') }}
</div>
<p class="mt-0.5 truncate text-xs text-neutral-500 dark:text-fg-dim">
{{ data_get($gtapp, 'html_url') }}
</p>
</div>
<x-loading wire:loading wire:target="loadRepositories({{ $gtapp->id }})" />
</button>
@endforeach
</div>
</section>
@elseif ($current_step === 'repository')
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Choose repository</h2>
<p>Search repositories available through the selected Gitea App.</p>
</div>
@if ($gitea_app_id)
<x-forms.button wire:click.prevent="loadRepositories({{ $gitea_app_id }})">
Refresh
</x-forms.button>
@endif
</div>
<div class="application-settings-section-body">
@if ($repositories->isNotEmpty())
<div class="flex items-end gap-2">
<x-forms.listbox id="selected_project_id" label="Repository" searchable live
placeholder="Select a repository" searchPlaceholder="Search repositories…"
:options="$repositories->map(fn ($repository) => [
'value' => data_get($repository, 'id'),
'label' => data_get($repository, 'full_name'),
])->values()->all()" />
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches"
wire:target="loadBranches,selected_project_id">
<x-loading-on-button wire:loading.delay
wire:target="loadBranches,selected_project_id" />
Load repository
</x-forms.button>
</div>
@else
<x-empty size="sm" title="No repositories available"
description="Review this Gitea App configuration and grant access to a repository." />
@endif
</div>
</section>
@if ($branches->isNotEmpty())
<form wire:submit="submit">
<section class="application-settings-section">
<div class="application-settings-section-header">
<div>
<h2>Build configuration</h2>
<p>Choose the branch and build strategy for this application.</p>
</div>
<x-forms.button type="submit" isHighlighted>Continue</x-forms.button>
</div>
<div class="application-settings-section-body space-y-5">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.listbox id="selected_branch_name" label="Branch" required
:options="$branches->map(fn ($branch) => [
'value' => data_get($branch, 'name'),
'label' => data_get($branch, 'name'),
])->values()->all()" />
<x-forms.listbox id="build_pack" label="Build pack" required live :options="[
['value' => 'railpack', 'label' => 'Railpack'],
['value' => 'nixpacks', 'label' => 'Nixpacks'],
['value' => 'static', 'label' => 'Static'],
['value' => 'dockerfile', 'label' => 'Dockerfile'],
['value' => 'dockercompose', 'label' => 'Docker Compose'],
]" />
@if ($show_is_static)
<x-forms.listbox id="is_static" label="Output type" onChange="instantSave"
:options="[
['value' => false, 'label' => 'Web application'],
['value' => true, 'label' => 'Static site'],
]" />
<x-forms.input type="number" id="port" label="Port"
:readonly="$is_static || $build_pack === 'static'"
helper="Port the application listens on." />
@endif
@if ($is_static)
<x-forms.input id="publish_directory" label="Publish directory"
helper="Directory containing the generated static assets." />
@endif
</div>
@if ($build_pack === 'dockercompose')
<x-forms.repository-paths class="sm:grid-cols-2" base="base_directory"
file="docker_compose_location" fileLabel="Compose file"
defaultFile="/docker-compose.yaml" />
@else
<x-forms.input wire:model="base_directory" label="Base directory"
helper="Repository directory used as the build root." />
@endif
</div>
</section>
</form>
@endif
@endif
</div>
@@ -8,6 +8,8 @@
<livewire:project.new.github-private-repository :type="$type" />
@elseif ($type === 'private-gitlab-app')
<livewire:project.new.gitlab-private-repository :type="$type" />
@elseif ($type === 'private-gitea-app')
<livewire:project.new.gitea-private-repository :type="$type" />
@elseif ($type === 'private-deploy-key')
<livewire:project.new.github-private-repository-deploy-key :type="$type" />
@elseif ($type === 'dockerfile')
@@ -0,0 +1,306 @@
<div>
<x-slot:title>
{{ $name ?: 'Gitea App' }} | Sources | Coolify
</x-slot>
@if ($isConnected)
<header class="mb-5 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">
{{ $name ?: 'Gitea App' }}
</h1>
<x-status-badge label="Connected" type="success" />
</div>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
{{ filled($organization) ? 'Gitea App for '.$organization : 'Private Gitea source' }}
</p>
</div>
<div class="flex shrink-0 flex-wrap items-center gap-2 sm:ml-auto">
@can('view', $gitea_app)
<x-forms.button type="button" wire:click.prevent="testConnection">
Test connection
</x-forms.button>
@endcan
@can('delete', $gitea_app)
<x-modal-confirmation title="Confirm Gitea App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected Gitea App will be permanently deleted.']"
confirmationText="{{ data_get($gitea_app, 'name') }}"
confirmationLabel="Please confirm by entering the Gitea App Name below"
shortConfirmationLabel="Gitea App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
@endcan
</div>
</header>
<form wire:submit="submit" class="application-settings-form">
<x-unsaved-bar action="submit" />
<x-application.settings-section title="General"
description="Connection and authentication settings for this private Gitea source.">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$gitea_app" id="name" label="Name" />
@if (! isCloud())
<div class="lg:col-span-2 max-w-xs">
<x-forms.checkbox canGate="update" :canResource="$gitea_app" label="System wide"
helper="If checked, this Gitea App will be available for everyone in this Coolify instance."
instantSave id="isSystemWide" />
</div>
@if ($isSystemWide)
<div class="lg:col-span-2">
<x-callout type="warning" title="Shared with every team">
System-wide Gitea Apps are shared across all teams on this Coolify instance. Prefer team-specific apps when you need repository isolation.
</x-callout>
</div>
@endif
@endif
</div>
</x-application.settings-section>
<x-application.settings-section title="OAuth credentials"
description="Client credentials from your Gitea OAuth2 application.">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$gitea_app" id="clientId"
label="Client ID" />
<x-forms.input canGate="update" :canResource="$gitea_app" id="clientSecretInput"
label="Client secret" type="password"
helper="Stored encrypted. Leave empty to keep the existing secret." />
<x-forms.input canGate="update" :canResource="$gitea_app" id="organization" label="Organization"
helper="Comma-separated organization or user names to filter visible repositories." />
</div>
</x-application.settings-section>
<x-application.settings-section title="Self-hosted / advanced"
description="Override endpoints and SSH settings for self-hosted Gitea.">
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input canGate="update" :canResource="$gitea_app" id="htmlUrl"
label="Gitea URL" />
<x-forms.input canGate="update" :canResource="$gitea_app" id="apiUrl" label="API URL" />
<x-forms.input canGate="update" :canResource="$gitea_app" id="customUser"
label="SSH user" />
<x-forms.input canGate="update" :canResource="$gitea_app" type="number" id="customPort"
label="SSH port" />
<div class="lg:col-span-2">
<x-forms.listbox canGate="update" :canResource="$gitea_app" id="privateKeyId" label="SSH private key (optional)"
:options="collect($privateKeys)->map(fn ($key) => [
'value' => $key->id,
'label' => $key->name,
])->prepend(['value' => null, 'label' => 'None'])->values()->all()"
:disabled="! auth()->user()->can('update', $gitea_app)" />
</div>
</div>
</x-application.settings-section>
<x-application.settings-section title="Webhook"
description="Configure this webhook URL in Gitea (user, organization, or repository settings → Webhooks → Add webhook → Gitea).">
<div class="grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2">
<x-forms.input readonly label="Webhook URL"
value="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitea/events" />
</div>
<x-forms.input canGate="update" :canResource="$gitea_app" id="webhookSecret"
label="Webhook secret" type="password"
helper="Set this as the Secret in a Gitea webhook (user, organization, or repository settings → Webhooks → Add webhook → Gitea), content type application/json, trigger on Push and Pull Request events. Stored encrypted." />
</div>
</x-application.settings-section>
</form>
<div x-data="{ search: '' }" class="application-settings-form mt-6">
<x-application.settings-section title="Resources"
description="Applications currently using this Gitea App." flush>
@if ($applications->isEmpty())
<x-empty title="No resources use this source"
description="Applications will appear here after this Gitea App is selected as their source."
icon-name="sources" size="sm" />
@else
<div class="border-b border-neutral-200 p-3 dark:border-white/[0.08]">
<div class="relative w-full max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" type="search" placeholder="Search resources"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-3! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
</div>
</div>
<div class="overflow-x-auto">
<div
class="grid min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
<div>Project</div>
<div>Environment</div>
<div>Resource</div>
<div>Repository</div>
</div>
@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
<a {{ wireNavigate() }}
href="{{ route('project.application.configuration', [
'project_uuid' => data_get($application, 'environment.project.uuid'),
'environment_uuid' => data_get($application, 'environment.uuid'),
'application_uuid' => data_get($application, 'uuid'),
]) }}"
x-show="search === '' || '{{ addslashes($searchValue) }}'.includes(search.toLowerCase())"
class="grid min-h-13 min-w-[680px] grid-cols-[minmax(10rem,.8fr)_minmax(10rem,.8fr)_minmax(12rem,1fr)_8rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $projectName }}</span>
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $environmentName }}</span>
<span class="truncate font-medium text-black dark:text-fg">{{ $resourceName }}</span>
<span class="truncate text-neutral-500 dark:text-fg-dim">{{ $repoLabel }}</span>
</a>
@endforeach
</div>
@endif
</x-application.settings-section>
</div>
@else
<header class="mb-8 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">
{{ $name ?: 'Gitea App' }}
</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Finish connecting this Gitea App before using it as a source
</p>
</div>
@can('delete', $gitea_app)
<div class="shrink-0 sm:ml-auto">
<x-modal-confirmation title="Confirm Gitea App Deletion?" isErrorButton buttonTitle="Delete"
submitAction="delete" :actions="['The selected Gitea App will be permanently deleted.']"
confirmationText="{{ data_get($gitea_app, 'name') }}"
confirmationLabel="Please confirm by entering the Gitea App Name below"
shortConfirmationLabel="Gitea App Name" :confirmWithPassword="false"
step2ButtonText="Permanently Delete" />
</div>
@endcan
</header>
<div class="application-settings-form flex flex-col gap-6"
x-data="{
webhookEndpoint: $wire.entangle('webhook_endpoint').live,
useCustomWebhookEndpoint: $wire.entangle('use_custom_webhook_endpoint').live,
customWebhookEndpoint: $wire.entangle('custom_webhook_endpoint').live,
redirectPath: '/webhooks/source/gitea/redirect',
get redirectUri() {
const base = (this.useCustomWebhookEndpoint ? this.customWebhookEndpoint : this.webhookEndpoint) || '';
return base ? base.replace(/\/+$/, '') + this.redirectPath : '';
}
}">
<x-application.settings-section title="Step 1 · Create an OAuth application"
description="In Gitea, open Manage OAuth2 Applications and create a new application with the redirect URI below.">
<div class="flex flex-col gap-3 text-[12px] leading-5 text-neutral-600 dark:text-fg-dim">
<a href="{{ rtrim($htmlUrl, '/') }}/user/settings/applications" target="_blank"
class="inline-flex w-fit items-center gap-1 font-medium text-black underline-offset-2 hover:underline dark:text-fg">
{{ rtrim($htmlUrl, '/') }}/user/settings/applications
<x-external-link />
</a>
<ul class="list-inside list-disc space-y-1.5">
<li>
Set <strong class="text-black dark:text-fg">Redirect URI</strong> to:
<code class="rounded bg-neutral-100 px-1.5 py-0.5 text-[11px] dark:bg-white/[0.06]"
x-text="redirectUri || @js($redirectUri)">{{ $redirectUri }}</code>
</li>
<li>
Keep <strong class="text-black dark:text-fg">Confidential client</strong> checked
</li>
</ul>
</div>
</x-application.settings-section>
<form wire:submit="submit" class="contents">
<x-application.settings-section title="Step 2 · Enter credentials"
description="Paste the Client ID and Client secret from Gitea, then save.">
<x-slot:actions>
<x-forms.button type="submit">Save</x-forms.button>
</x-slot:actions>
<div class="grid gap-4 lg:grid-cols-2">
<x-forms.input id="name" label="Name" />
<x-forms.input id="clientId" label="Client ID" required
helper="The Client ID from your Gitea OAuth2 application." />
<x-forms.input id="clientSecretInput" label="Client secret" type="password"
:required="blank($clientSecretInput) && blank(data_get($gitea_app, 'client_secret'))"
helper="The Client secret from your Gitea OAuth2 application. Saved encrypted." />
<x-forms.input id="organization" label="Organization"
helper="Optional. Comma-separated organization or user names to filter repositories." />
</div>
@if (! isCloud() || isDev())
<div class="mt-4 grid gap-4 lg:grid-cols-2">
<div class="lg:col-span-2 text-[12px] text-neutral-500 dark:text-fg-dim">
Gitea will redirect back to this Coolify URL. It must match the Redirect URI on your Gitea OAuth2 application exactly.
</div>
<div class="lg:col-span-2 max-w-md">
<x-forms.listbox id="use_custom_webhook_endpoint" label="Webhook endpoint"
:live="true" :options="[
['value' => false, 'label' => 'Use an instance endpoint'],
['value' => true, 'label' => 'Use a custom endpoint'],
]"
x-model="useCustomWebhookEndpoint"
helper="Use a custom public URL when Coolify is behind a tunnel or reverse proxy." />
</div>
<div class="lg:col-span-2" x-show="!useCustomWebhookEndpoint">
<x-forms.listbox id="webhook_endpoint" x-model="webhookEndpoint"
label="Selected endpoint"
helper="Gitea will use this endpoint unless custom mode is enabled."
:options="collect([$fqdn, $ipv4, $ipv6, config('app.url')])
->filter()->unique()->map(fn ($endpoint) => [
'value' => $endpoint,
'label' => 'Use '.$endpoint,
])->values()->all()" />
</div>
<div class="lg:col-span-2" x-cloak x-show="useCustomWebhookEndpoint">
<x-forms.input x-model="customWebhookEndpoint" id="custom_webhook_endpoint"
type="url" label="Custom endpoint"
placeholder="https://coolify.example.com"
helper="Gitea will use this custom public URL. Do not include /webhooks." />
</div>
</div>
@endif
<div class="mt-4" x-data="{ open: false }">
<button type="button" @click="open = !open"
class="flex w-full items-center justify-between rounded-lg border border-neutral-200 px-3 py-2.5 text-left text-[12px] font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.03]">
Advanced / self-hosted
<svg class="size-3.5 transition-transform" :class="{ 'rotate-180': open }"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div x-cloak x-show="open" x-collapse.duration.200ms
class="mt-3 grid gap-4 rounded-lg border border-neutral-200 p-3 lg:grid-cols-2 dark:border-white/[0.08]">
<x-forms.input id="htmlUrl" label="Gitea URL"
helper="Only change this for self-hosted Gitea (e.g. https://gitea.example.com)." />
<x-forms.input id="apiUrl" label="API URL"
helper="Usually your Gitea URL with /api/v1 appended." />
<x-forms.input id="customUser" label="SSH user" />
<x-forms.input type="number" id="customPort" label="SSH port" />
@if (! isCloud())
<div class="max-w-xs lg:col-span-2">
<x-forms.checkbox label="System wide" id="isSystemWide"
helper="If checked, this Gitea App will be available for everyone in this Coolify instance." />
</div>
@endif
</div>
</div>
</x-application.settings-section>
</form>
@if ($clientId)
<x-application.settings-section title="Step 3 · Authorize with Gitea"
description="Authorize Coolify with your Gitea instance. The redirect URI must match the one configured in Gitea.">
<a href="{{ $this->getOAuthUrl() }}" wire:key="oauth-url-{{ md5((string) $redirectUri) }}"
class="button button-highlighted">
Connect to Gitea
<x-external-link />
</a>
</x-application.settings-section>
@endif
</div>
@endif
</div>
@@ -0,0 +1,59 @@
@can('createAnyResource')
<form wire:submit="createGiteaApp" class="flex w-full flex-col gap-4">
<p class="text-[12px] leading-5 text-neutral-500 dark:text-fg-dim">
Connect a Gitea OAuth2 application for private repositories, webhooks, and deployments.
</p>
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input id="name" label="Name" required />
<x-forms.input id="organization" label="Organization"
helper="Optional. Comma-separated organization or user names to filter repositories (e.g. myorg,myuser)."
placeholder="All accessible repositories when empty" />
</div>
@if (! isCloud())
<div x-data="{ showWarning: @entangle('is_system_wide') }">
<div class="max-w-xs">
<x-forms.checkbox id="is_system_wide" label="System wide"
helper="If checked, this Gitea App will be available for everyone in this Coolify instance." />
</div>
<div x-cloak x-show="showWarning" x-transition class="mt-3">
<x-callout type="warning" title="Shared with every team">
System-wide Gitea Apps are available to every team on this instance. Prefer team-specific apps when you need repository isolation.
</x-callout>
</div>
</div>
@endif
<div x-data="{
open: false,
}" class="rounded-lg border border-neutral-200 dark:border-white/[0.08]">
<button type="button" @click="open = !open"
class="flex w-full items-center justify-between px-3 py-2.5 text-left text-[12px] font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-fg-dim dark:hover:bg-white/[0.03]">
Self-hosted Gitea
<svg class="size-3.5 transition-transform" :class="{ 'rotate-180': open }" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div x-cloak x-show="open" x-collapse.duration.200ms class="border-t border-neutral-200 px-3 py-3 dark:border-white/[0.08]">
<div class="grid gap-4 sm:grid-cols-2">
<x-forms.input id="html_url" label="Gitea URL" required
helper="For self-hosted Gitea, enter your instance URL (e.g. https://gitea.example.com)." />
<x-forms.input id="api_url" label="API URL" required
helper="Usually your Gitea URL with /api/v1 appended." />
<x-forms.input id="custom_user" label="Custom Git user" required />
<x-forms.input id="custom_port" type="number" label="Custom Git port" required />
</div>
</div>
</div>
<x-forms.button class="mt-1 w-full justify-center" type="submit">
Continue
</x-forms.button>
</form>
@else
<x-callout type="danger" title="Insufficient permissions">
You don't have permission to create new Gitea Apps. Contact your team administrator.
</x-callout>
@endcan
+49 -7
View File
@@ -44,6 +44,16 @@
</x-slot:content>
<livewire:source.gitlab.create />
</x-modal-input>
<x-modal-input title="New Gitea App" :closeOutside="false">
<x-slot:content>
<button type="button" @click="dropdownOpen = false"
class="listbox-option justify-start! gap-2.5!" role="menuitem">
<x-git-icon class="size-3.5 shrink-0 opacity-70" git="App\Models\GiteaApp" />
Gitea App
</button>
</x-slot:content>
<livewire:source.gitea.create />
</x-modal-input>
</div>
</div>
@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 @@
</div>
</div>
<div class="mt-auto pt-4">
@if ($source->isConnected())
<x-status-badge label="Connected" type="success" />
@else
<x-status-badge label="Setup required" type="warning" />
@endif
</div>
</a>
@elseif ($source->getMorphClass() === 'App\Models\GiteaApp')
<a x-show="matches(@js([$source->name, 'Gitea', $source->organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" class="group flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:no-underline hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.05] dark:hover:border-white/[0.14]"
{{ wireNavigate() }}
href="{{ route('source.gitea.show', ['gitea_app_uuid' => data_get($source, 'uuid')]) }}">
<div class="flex items-start gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<x-git-icon class="size-4" git="App\Models\GiteaApp" />
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg">
{{ $source->name }}
</h2>
<p class="mt-0.5 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
{{ $source->organization ? "Gitea · {$source->organization}" : 'Gitea' }}
</p>
</div>
</div>
<div class="mt-auto pt-4">
@if ($source->isConnected())
<x-status-badge label="Connected" type="success" />
@@ -135,10 +176,11 @@
<div class="grid min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint"><div>Source</div><div>Provider</div><div>Status</div></div>
@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
<a x-show="matches(@js([$source->name, $provider, $organization, $source->isConnected() ? 'Connected' : 'Setup incomplete']))" {{ wireNavigate() }} href="{{ $href }}" class="grid min-h-14 min-w-[620px] grid-cols-[minmax(0,1fr)_minmax(10rem,.7fr)_9rem] items-center border-b border-neutral-200 px-4 py-2.5 text-[12px] transition-colors last:border-b-0 hover:bg-neutral-50 hover:no-underline dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="truncate font-semibold text-black dark:text-fg">{{ $source->name }}</div>
+2
View File
@@ -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 () {
+2
View File
@@ -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']);
@@ -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();
});
@@ -0,0 +1,96 @@
<?php
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GiteaApp;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->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();
});
@@ -0,0 +1,153 @@
<?php
use App\Livewire\Source\Gitea\Change as GiteaSource;
use App\Models\GiteaApp;
use App\Models\Team;
use App\Models\User;
use App\Rules\SafeExternalUrl;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request as HttpRequest;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
Http::macro('GitSource', fn (string $url) => 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();
});
});
@@ -0,0 +1,127 @@
<?php
use App\Livewire\Project\New\GiteaPrivateRepository;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GiteaApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
/**
* Http::GitSource() pins DNS before Http::fake() answers, so use a public IP
* literal that needs no DNS lookup. No request leaves the test.
*/
function giteaPickerBaseUrl(): string
{
return 'https://1.1.1.1';
}
function giteaPickerSourceForTeam(Team $team, string $name): GiteaApp
{
return GiteaApp::create([
'name' => $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);
});
+214
View File
@@ -0,0 +1,214 @@
<?php
use App\Livewire\Source\Gitea\Change;
use App\Models\GiteaApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
uses(RefreshDatabase::class);
function makeGiteaChangeViewApp(Team $team, array $attributes = []): GiteaApp
{
return GiteaApp::create(array_merge([
'name' => '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');
});
});
@@ -0,0 +1,65 @@
<?php
use App\Livewire\Source\Gitea\Create;
use App\Models\GiteaApp;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
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]);
});
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();
});
});
+59
View File
@@ -0,0 +1,59 @@
<?php
use App\Models\GiteaApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 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();
});
@@ -0,0 +1,236 @@
<?php
use App\Actions\Application\CleanupPreviewDeployment;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\GiteaApp;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Testing\TestResponse;
use Tests\TestCase;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Queue::fake();
InstanceSettings::forceCreate(['id' => 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<string, mixed>
*/
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<string, string> $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');
});
});
+170
View File
@@ -0,0 +1,170 @@
<?php
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\GiteaApp;
use App\Models\PrivateKey;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Collection;
beforeEach(function () {
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
});
afterEach(function () {
Model::encryptUsing(null);
});
function giteaCommandsAsString(array|Collection|string $commands): string
{
if (is_string($commands)) {
return $commands;
}
return collect($commands)->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',
]);
});
+192
View File
@@ -0,0 +1,192 @@
<?php
use App\Models\GiteaApp;
use App\Models\User;
use App\Policies\GiteaAppPolicy;
it('allows any user to view any gitea apps', function () {
$user = Mockery::mock(User::class)->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;
}
@@ -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);
@@ -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 = [