mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
feat(gitea): add Gitea App sources and private repo deployments (#12234)
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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')]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [
|
||||
[
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user