mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-23 18:25:03 +00:00
feat: self-hosted GitLab Apps OAuth integration
Adds self-hosted GitLab OAuth sources so Coolify can connect to a self-managed GitLab instance, list private repositories, clone over an OAuth token, and deploy (the GitLab counterpart to GitHub Apps). Hardening: authenticated, one-time team-bound OAuth callback state; token redaction in deploy logs; custom host port/path kept in clone and ls-remote URLs; submodule OAuth auth; system-wide source selection. Covered by unit and feature tests. cosigned by OpenAI Codex at M1 Max
This commit is contained in:
committed by
Andras Bacsai
parent
9d341d0bb9
commit
a26091de0a
@@ -6,10 +6,14 @@ use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
|
||||
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
|
||||
use App\Livewire\Source\Gitlab\Change as GitlabSource;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\GitlabApp;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Gitlab extends Controller
|
||||
@@ -17,6 +21,302 @@ class Gitlab extends Controller
|
||||
use DetectsSkipDeployCommits;
|
||||
use MatchesManualWebhookApplications;
|
||||
|
||||
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 GitLab 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(GitlabSource::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 GitLab OAuth state. Please start the authorization again.');
|
||||
}
|
||||
|
||||
$gitlabApp = GitlabApp::whereKey(data_get($payload, 'gitlab_app_id'))->firstOrFail();
|
||||
|
||||
$baseUrl = rtrim($gitlabApp->html_url, '/');
|
||||
|
||||
$response = Http::asForm()->post("{$baseUrl}/oauth/token", [
|
||||
'client_id' => $gitlabApp->client_id,
|
||||
'client_secret' => $gitlabApp->client_secret,
|
||||
'code' => $code,
|
||||
'grant_type' => 'authorization_code',
|
||||
'redirect_uri' => $gitlabApp->redirect_uri,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = data_get($response->json(), 'error_description', 'Token exchange failed');
|
||||
|
||||
return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid])
|
||||
->with('error', "GitLab OAuth failed: {$error}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$gitlabApp->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'expires_at' => time() + ($data['expires_in'] ?? 7200),
|
||||
]);
|
||||
|
||||
return redirect()->route('source.gitlab.show', ['gitlab_app_uuid' => $gitlabApp->uuid]);
|
||||
} catch (Exception $e) {
|
||||
return redirect()->route('source.all')->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function normal(Request $request)
|
||||
{
|
||||
try {
|
||||
$return_payloads = collect([]);
|
||||
$payload = $request->collect();
|
||||
$x_gitlab_token = $request->header('X-Gitlab-Token');
|
||||
$object_kind = data_get($payload, 'object_kind');
|
||||
$project_id = data_get($payload, 'project.id');
|
||||
|
||||
$allowed_events = ['push', 'merge_request'];
|
||||
if (! in_array($object_kind, $allowed_events)) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Event not allowed. Only push and merge_request events are allowed.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (empty($x_gitlab_token)) {
|
||||
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
|
||||
'event' => $object_kind,
|
||||
]);
|
||||
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Missing X-Gitlab-Token header.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$gitlab_app = GitlabApp::where('webhook_token', $x_gitlab_token)->first();
|
||||
if (! $gitlab_app) {
|
||||
auditLogWebhookFailure('gitlab', 'invalid_token', [
|
||||
'event' => $object_kind,
|
||||
]);
|
||||
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'Invalid webhook token.',
|
||||
], 401);
|
||||
}
|
||||
|
||||
$applications = Application::where('source_id', $gitlab_app->id)
|
||||
->where('source_type', GitlabApp::class)
|
||||
->where('repository_project_id', $project_id);
|
||||
|
||||
if ($object_kind === 'push') {
|
||||
$branch = data_get($payload, 'ref');
|
||||
if (Str::isMatch('/refs\/heads\/*/', $branch)) {
|
||||
$branch = Str::after($branch, 'refs/heads/');
|
||||
}
|
||||
if (! $branch) {
|
||||
return response([
|
||||
'status' => 'failed',
|
||||
'message' => 'No branch found in the request.',
|
||||
]);
|
||||
}
|
||||
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
$added_files = data_get($payload, 'commits.*.added');
|
||||
$removed_files = data_get($payload, 'commits.*.removed');
|
||||
$modified_files = data_get($payload, 'commits.*.modified');
|
||||
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
|
||||
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
|
||||
if (! $is_watch_path_triggered && ! blank($application->watch_paths)) {
|
||||
$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 Cuid2;
|
||||
$result = queue_application_deployment(
|
||||
application: $application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: data_get($payload, 'after', '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' => 'gitlab',
|
||||
'mode' => 'app',
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid->toString(),
|
||||
'commit' => data_get($payload, 'after'),
|
||||
]);
|
||||
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => $result['status'] ?? 'success',
|
||||
'message' => $result['message'] ?? 'Deployment queued.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($object_kind === 'merge_request') {
|
||||
$action = data_get($payload, 'object_attributes.action');
|
||||
$branch = data_get($payload, 'object_attributes.source_branch');
|
||||
$base_branch = data_get($payload, 'object_attributes.target_branch');
|
||||
$pull_request_id = data_get($payload, 'object_attributes.iid');
|
||||
$pull_request_html_url = data_get($payload, 'object_attributes.url');
|
||||
$pull_request_title = data_get($payload, 'object_attributes.title');
|
||||
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
|
||||
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
|
||||
|
||||
$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, ['open', 'opened', 'synchronize', 'reopened', 'reopen', 'update'])) {
|
||||
if (! $application->isPRDeployable()) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'failed',
|
||||
'message' => 'Preview deployments disabled',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($skip_deploy_pr) {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
'message' => 'PR title or latest commit contains [skip cd] or [skip ci].',
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$deployment_uuid = new Cuid2;
|
||||
$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' => 'gitlab',
|
||||
'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' => 'gitlab',
|
||||
'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: data_get($payload, 'object_attributes.last_commit.id', 'HEAD'),
|
||||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
git_type: 'gitlab',
|
||||
);
|
||||
|
||||
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 (in_array($action, ['closed', 'close', 'merge'])) {
|
||||
$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 (Exception $e) {
|
||||
return handleError($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function manual(Request $request)
|
||||
{
|
||||
try {
|
||||
@@ -291,8 +591,6 @@ class Gitlab extends Controller
|
||||
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Use comprehensive cleanup that cancels active deployments,
|
||||
// kills helper containers, and removes all PR containers
|
||||
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||
|
||||
$return_payloads->push([
|
||||
|
||||
@@ -96,9 +96,19 @@ class Source extends Component
|
||||
|
||||
private function getSources()
|
||||
{
|
||||
// filter the current source out
|
||||
$this->sources = currentTeam()->sources()->whereNotNull('app_id')->reject(function ($source) {
|
||||
return $source->id === $this->application->source_id;
|
||||
$this->sources = currentTeam()->sources()->filter(function ($source) {
|
||||
if ($source->id === $this->application->source_id
|
||||
&& $source->getMorphClass() === $this->application->source_type) {
|
||||
return false;
|
||||
}
|
||||
if ($source instanceof GithubApp) {
|
||||
return ! is_null($source->app_id);
|
||||
}
|
||||
if ($source instanceof GitlabApp) {
|
||||
return $source->isConnected();
|
||||
}
|
||||
|
||||
return true;
|
||||
})->sortBy('name');
|
||||
}
|
||||
|
||||
@@ -137,7 +147,6 @@ class Source extends Component
|
||||
|
||||
public function changeSource($sourceId, $sourceType)
|
||||
{
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
$allowedSourceTypes = [GithubApp::class, GitlabApp::class];
|
||||
@@ -150,16 +159,24 @@ class Source extends Component
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
['repository' => $customRepository] = $this->application->customRepository();
|
||||
$repository = githubApi($this->application->source, "repos/{$customRepository}");
|
||||
$data = data_get($repository, 'data');
|
||||
$repository_project_id = data_get($data, 'id');
|
||||
if (isset($repository_project_id)) {
|
||||
if ($this->application->repository_project_id !== $repository_project_id) {
|
||||
$this->application->repository_project_id = $repository_project_id;
|
||||
$this->application->save();
|
||||
$repository_project_id = null;
|
||||
|
||||
if ($sourceType === GithubApp::class) {
|
||||
$repository = githubApi($source, "repos/{$customRepository}");
|
||||
$repository_project_id = data_get($repository, 'data.id');
|
||||
} elseif ($sourceType === GitlabApp::class) {
|
||||
if ($source->isConnected()) {
|
||||
$encoded = urlencode($customRepository);
|
||||
$project = gitlabApi($source, "/projects/{$encoded}");
|
||||
$repository_project_id = data_get($project, 'data.id');
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) {
|
||||
$this->application->repository_project_id = $repository_project_id;
|
||||
$this->application->save();
|
||||
}
|
||||
|
||||
$this->application->refresh();
|
||||
$this->getSources();
|
||||
$this->dispatch('success', 'Source updated!');
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Component;
|
||||
|
||||
class GitlabPrivateRepository extends Component
|
||||
{
|
||||
public $current_step = 'gitlab_apps';
|
||||
|
||||
public $gitlab_apps;
|
||||
|
||||
public ?int $gitlab_app_id = null;
|
||||
|
||||
public $parameters;
|
||||
|
||||
public $currentRoute;
|
||||
|
||||
public $query;
|
||||
|
||||
public $type;
|
||||
|
||||
public int $selected_project_id;
|
||||
|
||||
public int $selected_gitlab_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 = 'nixpacks';
|
||||
|
||||
public bool $show_is_static = true;
|
||||
|
||||
private function getGitlabApp(): GitlabApp
|
||||
{
|
||||
return GitlabApp::private()->where('id', $this->gitlab_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->gitlab_apps = GitlabApp::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($gitlab_app_id)
|
||||
{
|
||||
$this->repositories = collect();
|
||||
$this->branches = collect();
|
||||
$this->total_branches_count = 0;
|
||||
$this->page = 1;
|
||||
$this->selected_gitlab_app_id = $gitlab_app_id;
|
||||
$this->gitlab_app_id = $gitlab_app_id;
|
||||
$gitlab_app = $this->getGitlabApp();
|
||||
|
||||
try {
|
||||
$result = loadGitlabRepositories($gitlab_app, $this->page);
|
||||
$this->repositories = $this->repositories->concat(collect($result['repositories']));
|
||||
|
||||
while ($result['has_more'] && $this->page < 50) {
|
||||
$this->page++;
|
||||
$result = loadGitlabRepositories($gitlab_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, 'path_with_namespace');
|
||||
}
|
||||
$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, 'path_with_namespace', $this->selected_repository_path);
|
||||
$this->branches = collect();
|
||||
$this->page = 1;
|
||||
$gitlab_app = $this->getGitlabApp();
|
||||
|
||||
try {
|
||||
$branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $this->page);
|
||||
$this->total_branches_count = count($branches);
|
||||
$this->branches = $this->branches->concat(collect($branches));
|
||||
|
||||
while ($this->total_branches_count === 100) {
|
||||
$this->page++;
|
||||
$branches = loadGitlabBranches($gitlab_app, $this->selected_project_id, $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 {
|
||||
$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();
|
||||
|
||||
$gitlab_app = $this->getGitlabApp();
|
||||
|
||||
$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' => $gitlab_app->id,
|
||||
'source_type' => $gitlab_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!');
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,12 @@ class Select extends Component
|
||||
'description' => 'You can deploy public & private repositories through your GitHub Apps.',
|
||||
'logo' => asset('svgs/github.svg'),
|
||||
],
|
||||
[
|
||||
'id' => 'private-gitlab-app',
|
||||
'name' => 'Private Repository (with GitLab App)',
|
||||
'description' => 'You can deploy public & private repositories through your GitLab Apps.',
|
||||
'logo' => asset('svgs/gitlab.svg'),
|
||||
],
|
||||
[
|
||||
'id' => 'private-deploy-key',
|
||||
'name' => 'Private Repository (with Deploy Key)',
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Source\Gitlab;
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class Change extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $webhook_endpoint = '';
|
||||
|
||||
public ?string $ipv4 = null;
|
||||
|
||||
public ?string $ipv6 = null;
|
||||
|
||||
public ?string $fqdn = null;
|
||||
|
||||
public $parameters;
|
||||
|
||||
public ?GitlabApp $gitlab_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 $webhookToken = null;
|
||||
|
||||
public ?string $groupName = null;
|
||||
|
||||
public bool $isSystemWide;
|
||||
|
||||
public ?int $privateKeyId = null;
|
||||
|
||||
public $applications;
|
||||
|
||||
public $privateKeys;
|
||||
|
||||
public bool $isConnected = false;
|
||||
|
||||
public ?string $redirectUri = null;
|
||||
|
||||
public ?string $oauthState = null;
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'required|string',
|
||||
'apiUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'htmlUrl' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'customUser' => 'required|string',
|
||||
'customPort' => 'required|int',
|
||||
'clientId' => 'nullable|string',
|
||||
'clientSecretInput' => 'nullable|string',
|
||||
'webhookToken' => 'nullable|string',
|
||||
'groupName' => 'nullable|string',
|
||||
'isSystemWide' => 'required|bool',
|
||||
'privateKeyId' => 'nullable|int',
|
||||
];
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
try {
|
||||
$gitlab_app_uuid = request()->gitlab_app_uuid;
|
||||
$this->gitlab_app = GitlabApp::where(function ($query) {
|
||||
$query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true);
|
||||
})->whereUuid($gitlab_app_uuid)->firstOrFail();
|
||||
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
||||
$this->applications = $this->gitlab_app->applications;
|
||||
|
||||
$settings = instanceSettings();
|
||||
|
||||
$this->syncData(false);
|
||||
|
||||
$this->isConnected = $this->gitlab_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') ?? '';
|
||||
}
|
||||
|
||||
$this->redirectUri = $this->webhook_endpoint.'/webhooks/source/gitlab/redirect';
|
||||
|
||||
$this->oauthState = $this->createOAuthState();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public static function oauthStateCacheKey(string $state): string
|
||||
{
|
||||
return 'gitlab-app-oauth-state:'.hash('sha256', $state);
|
||||
}
|
||||
|
||||
private function createOAuthState(): string
|
||||
{
|
||||
$state = Str::random(64);
|
||||
|
||||
Cache::put(self::oauthStateCacheKey($state), [
|
||||
'gitlab_app_id' => $this->gitlab_app->id,
|
||||
'team_id' => currentTeam()->id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
return $state;
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->gitlab_app->name = $this->name;
|
||||
$this->gitlab_app->api_url = $this->apiUrl;
|
||||
$this->gitlab_app->html_url = rtrim($this->htmlUrl, '/');
|
||||
$this->gitlab_app->custom_user = $this->customUser;
|
||||
$this->gitlab_app->custom_port = $this->customPort;
|
||||
$this->gitlab_app->client_id = $this->clientId;
|
||||
if (! empty($this->clientSecretInput)) {
|
||||
$this->gitlab_app->client_secret = $this->clientSecretInput;
|
||||
}
|
||||
$this->gitlab_app->webhook_token = $this->webhookToken;
|
||||
$this->gitlab_app->group_name = $this->groupName;
|
||||
$this->gitlab_app->is_system_wide = $this->isSystemWide;
|
||||
$this->gitlab_app->private_key_id = $this->privateKeyId;
|
||||
$this->gitlab_app->redirect_uri = $this->redirectUri;
|
||||
} else {
|
||||
$this->name = $this->gitlab_app->name;
|
||||
$this->apiUrl = $this->gitlab_app->api_url;
|
||||
$this->htmlUrl = $this->gitlab_app->html_url;
|
||||
$this->customUser = $this->gitlab_app->custom_user;
|
||||
$this->customPort = $this->gitlab_app->custom_port;
|
||||
$this->clientId = $this->gitlab_app->client_id;
|
||||
$this->clientSecretInput = null;
|
||||
$this->webhookToken = $this->gitlab_app->webhook_token;
|
||||
$this->groupName = $this->gitlab_app->group_name;
|
||||
$this->isSystemWide = $this->gitlab_app->is_system_wide;
|
||||
$this->privateKeyId = $this->gitlab_app->private_key_id;
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->gitlab_app);
|
||||
|
||||
$this->validate();
|
||||
|
||||
$this->syncData(true);
|
||||
$this->gitlab_app->save();
|
||||
$this->dispatch('success', 'GitLab App updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->gitlab_app);
|
||||
|
||||
$this->gitlab_app->makeVisible(['client_secret', 'webhook_token', 'access_token', 'refresh_token']);
|
||||
$this->syncData(true);
|
||||
$this->gitlab_app->save();
|
||||
$this->dispatch('success', 'GitLab App updated.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnection()
|
||||
{
|
||||
try {
|
||||
$this->authorize('view', $this->gitlab_app);
|
||||
|
||||
if (! $this->gitlab_app->isConnected()) {
|
||||
$this->dispatch('error', 'GitLab App is not connected. Please complete the OAuth flow first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
refreshGitlabToken($this->gitlab_app);
|
||||
|
||||
$apiUrl = $this->gitlab_app->apiUrlBase();
|
||||
$response = Http::GitLab($apiUrl, $this->gitlab_app->access_token)
|
||||
->timeout(10)
|
||||
->get('/user');
|
||||
|
||||
if ($response->successful()) {
|
||||
$username = data_get($response->json(), 'username', 'unknown');
|
||||
$this->dispatch('success', "Connection successful! Authenticated as: {$username}");
|
||||
} else {
|
||||
$error = data_get($response->json(), 'message', 'Unknown error');
|
||||
$this->dispatch('error', "Connection failed: {$error}");
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function disconnect()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->gitlab_app);
|
||||
|
||||
$this->gitlab_app->update([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
'expires_at' => null,
|
||||
]);
|
||||
|
||||
$this->isConnected = false;
|
||||
$this->dispatch('success', 'GitLab App disconnected.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
$this->authorize('delete', $this->gitlab_app);
|
||||
|
||||
if ($this->gitlab_app->applications->isNotEmpty()) {
|
||||
$this->dispatch('error', 'This source is being used by an application. Please delete all applications first.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->gitlab_app->delete();
|
||||
|
||||
return redirect()->route('source.all');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function getOAuthUrl(): string
|
||||
{
|
||||
$baseUrl = rtrim($this->htmlUrl, '/');
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => 'api read_user read_repository',
|
||||
'state' => $this->oauthState ??= $this->createOAuthState(),
|
||||
]);
|
||||
|
||||
return "{$baseUrl}/oauth/authorize?{$query}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Source\Gitlab;
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $name;
|
||||
|
||||
public string $html_url = 'https://gitlab.com';
|
||||
|
||||
public bool $is_system_wide = false;
|
||||
|
||||
public ?string $group_name = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->name = substr(generate_random_name(), 0, 30);
|
||||
}
|
||||
|
||||
public function createGitLabApp()
|
||||
{
|
||||
try {
|
||||
$this->authorize('createAnyResource');
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string',
|
||||
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
|
||||
'is_system_wide' => 'required|bool',
|
||||
'group_name' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$htmlUrl = rtrim($this->html_url, '/');
|
||||
$apiUrl = $htmlUrl.'/api/v4';
|
||||
|
||||
$gitlab_app = GitlabApp::create([
|
||||
'name' => $this->name,
|
||||
'api_url' => $apiUrl,
|
||||
'html_url' => $htmlUrl,
|
||||
'is_system_wide' => $this->is_system_wide,
|
||||
'group_name' => $this->group_name,
|
||||
'webhook_token' => Str::random(32),
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
if (session('from')) {
|
||||
session(['from' => session('from') + ['source_id' => $gitlab_app->id]]);
|
||||
}
|
||||
|
||||
return redirectRoute($this, 'source.gitlab.show', ['gitlab_app_uuid' => $gitlab_app->uuid]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1378,11 +1378,11 @@ class Application extends BaseModel
|
||||
|
||||
if ($this->deploymentType() === 'source') {
|
||||
$source_html_url = data_get($this, 'source.html_url');
|
||||
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL));
|
||||
$source_html_url_host = $url['host'];
|
||||
$source_html_url_scheme = $url['scheme'];
|
||||
|
||||
if ($this->source->getMorphClass() == 'App\Models\GithubApp') {
|
||||
$url = parse_url(filter_var($source_html_url, FILTER_SANITIZE_URL)) ?: [];
|
||||
$source_html_url_host = $url['host'] ?? '';
|
||||
$source_html_url_scheme = $url['scheme'] ?? '';
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
if ($this->source->is_public) {
|
||||
$escapedRepoUrl = escapeshellarg("{$this->source->html_url}/{$customRepository}");
|
||||
@@ -1420,6 +1420,32 @@ class Application extends BaseModel
|
||||
|
||||
if ($this->source->getMorphClass() === GitlabApp::class) {
|
||||
$gitlabSource = $this->source;
|
||||
|
||||
if ($gitlabSource->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);
|
||||
$encodedToken = rawurlencode($token);
|
||||
$pathPrefix = rtrim($url['path'] ?? '', '/');
|
||||
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$fullRepoUrl = $repoUrl;
|
||||
$base_command = "{$base_command} {$escapedRepoUrl}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||
|
||||
if ($private_key) {
|
||||
@@ -1444,7 +1470,6 @@ class Application extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
@@ -1677,6 +1702,52 @@ class Application extends BaseModel
|
||||
|
||||
if ($this->source->getMorphClass() === GitlabApp::class) {
|
||||
$gitlabSource = $this->source;
|
||||
|
||||
if ($gitlabSource->isConnected()) {
|
||||
$token = generateGitlabCloneToken($gitlabSource);
|
||||
$encodedToken = rawurlencode($token);
|
||||
$pathPrefix = rtrim($url['path'] ?? '', '/');
|
||||
$source_html_url_host = $this->urlHostWithPort($url ?: []);
|
||||
|
||||
// Rewrite same-host HTTPS submodule URLs to auth with the OAuth token (mirrors the GitHub path) without persisting credentials.
|
||||
$gitConfigOption = '-c '.escapeshellarg("url.{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/.insteadOf={$source_html_url_scheme}://{$source_html_url_host}{$pathPrefix}/");
|
||||
$gitConfigOptions = $this->withGitHttpTransportConfig($gitConfigOption);
|
||||
|
||||
$repoUrl = "{$source_html_url_scheme}://oauth2:{$encodedToken}@{$source_html_url_host}{$pathPrefix}/{$customRepository}.git";
|
||||
$escapedRepoUrl = escapeshellarg($repoUrl);
|
||||
$fullRepoUrl = $repoUrl;
|
||||
$git_clone_command_base = $this->applyGitConfigOptionsToCloneCommand("{$git_clone_command} {$escapedRepoUrl} {$escapedBaseDir}", $gitConfigOptions);
|
||||
if ($only_checkout) {
|
||||
$git_clone_command = $git_clone_command_base;
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:{$pr_branch_name}";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out {$branch}'"));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out {$branch}'");
|
||||
}
|
||||
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOptions);
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && git {$gitConfigOptions} fetch origin {$escapedPrBranch} && {$git_checkout_command}";
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
|
||||
$private_key = data_get($gitlabSource, 'privateKey.private_key');
|
||||
|
||||
if ($private_key) {
|
||||
@@ -1717,7 +1788,6 @@ class Application extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
// GitLab source without private key — use URL as-is (supports user-embedded basic auth)
|
||||
$fullRepoUrl = $customRepository;
|
||||
$escapedCustomRepository = escapeshellarg($customRepository);
|
||||
$git_clone_command = "{$git_clone_command} {$escapedCustomRepository} {$escapedBaseDir}";
|
||||
@@ -2382,6 +2452,14 @@ class Application extends BaseModel
|
||||
];
|
||||
}
|
||||
|
||||
private function urlHostWithPort(array $url): string
|
||||
{
|
||||
$host = $url['host'] ?? '';
|
||||
$port = isset($url['port']) ? ":{$url['port']}" : '';
|
||||
|
||||
return "{$host}{$port}";
|
||||
}
|
||||
|
||||
public function generateConfig($is_json = false)
|
||||
{
|
||||
$generator = new ConfigurationGenerator($this);
|
||||
|
||||
@@ -16,20 +16,68 @@ class GitlabApp extends BaseModel
|
||||
'app_id',
|
||||
'app_secret',
|
||||
'oauth_id',
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'expires_at',
|
||||
'redirect_uri',
|
||||
'group_name',
|
||||
'public_key',
|
||||
'webhook_token',
|
||||
'deploy_key_id',
|
||||
'private_key_id',
|
||||
'team_id',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'webhook_token',
|
||||
'app_secret',
|
||||
'client_secret',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'access_token' => 'encrypted',
|
||||
'refresh_token' => 'encrypted',
|
||||
'client_secret' => 'encrypted',
|
||||
'is_system_wide' => 'boolean',
|
||||
'is_public' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::deleting(function (GitlabApp $gitlabApp) {
|
||||
if ($gitlabApp->applications()->count() > 0) {
|
||||
throw new \RuntimeException('This source is being used by an application. Please delete all applications first.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return GitlabApp::whereTeamId(currentTeam()->id);
|
||||
return GitlabApp::where(function ($query) {
|
||||
$query->where('team_id', currentTeam()->id)
|
||||
->orWhere('is_system_wide', true);
|
||||
});
|
||||
}
|
||||
|
||||
public static function public()
|
||||
{
|
||||
return GitlabApp::where(function ($query) {
|
||||
$query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true);
|
||||
})->where('is_public', true);
|
||||
}
|
||||
|
||||
public static function private()
|
||||
{
|
||||
return GitlabApp::where(function ($query) {
|
||||
$query->where('team_id', currentTeam()->id)->orWhere('is_system_wide', true);
|
||||
})->where('is_public', false)->whereNotNull('access_token');
|
||||
}
|
||||
|
||||
public function applications()
|
||||
@@ -41,4 +89,25 @@ class GitlabApp extends BaseModel
|
||||
{
|
||||
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/v4')) {
|
||||
$apiUrl .= '/api/v4';
|
||||
}
|
||||
|
||||
return $apiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\User;
|
||||
|
||||
class GitlabAppPolicy
|
||||
{
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function view(User $user, GitlabApp $gitlabApp): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(User $user, GitlabApp $gitlabApp): bool
|
||||
{
|
||||
if ($gitlabApp->is_system_wide) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(User $user, GitlabApp $gitlabApp): bool
|
||||
{
|
||||
if ($gitlabApp->is_system_wide) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function restore(User $user, GitlabApp $gitlabApp): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function forceDelete(User $user, GitlabApp $gitlabApp): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -102,5 +102,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
])->baseUrl($api_url);
|
||||
}
|
||||
});
|
||||
|
||||
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
|
||||
$client = Http::withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
])->baseUrl($api_url);
|
||||
if ($access_token) {
|
||||
$client = $client->withToken($access_token);
|
||||
}
|
||||
|
||||
return $client;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
@@ -51,6 +52,7 @@ use App\Policies\DatabasePolicy;
|
||||
use App\Policies\EnvironmentPolicy;
|
||||
use App\Policies\EnvironmentVariablePolicy;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
use App\Policies\GitlabAppPolicy;
|
||||
use App\Policies\InstanceSettingsPolicy;
|
||||
use App\Policies\NotificationPolicy;
|
||||
use App\Policies\PrivateKeyPolicy;
|
||||
@@ -127,6 +129,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
// Git source policies
|
||||
GithubApp::class => GithubAppPolicy::class,
|
||||
GitlabApp::class => GitlabAppPolicy::class,
|
||||
|
||||
// Cloud provider policies
|
||||
CloudProviderToken::class => CloudProviderTokenPolicy::class,
|
||||
CloudInitScript::class => CloudInitScriptPolicy::class,
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -216,7 +215,7 @@ function generateGithubJwt(GithubApp $source)
|
||||
return generateGithubToken($source, 'jwt');
|
||||
}
|
||||
|
||||
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
{
|
||||
if (is_null($source)) {
|
||||
throw new Exception('Source is required for API calls');
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function refreshGitlabToken(GitlabApp $source): void
|
||||
{
|
||||
if (! $source->refresh_token) {
|
||||
throw new RuntimeException('GitLab source has no refresh token. Please reconnect the GitLab app.');
|
||||
}
|
||||
|
||||
$safetyMargin = 60;
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("gitlab_token_refresh_{$source->id}", 20);
|
||||
if (! $lock->block(20)) {
|
||||
$source->refresh();
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('GitLab 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::asForm()->post("{$baseUrl}/oauth/token", [
|
||||
'client_id' => $source->client_id,
|
||||
'client_secret' => $source->client_secret,
|
||||
'refresh_token' => $source->refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
'redirect_uri' => $source->redirect_uri,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = data_get($response->json(), 'error_description', $response->body());
|
||||
throw new RuntimeException("Failed to refresh GitLab token: {$error}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$source->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'expires_at' => time() + ($data['expires_in'] ?? 7200),
|
||||
]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
function gitlabApi(GitlabApp $source, string $endpoint, string $method = 'get', ?array $data = null): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
|
||||
$client = Http::GitLab($apiUrl, $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("GitLab API call failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => collect($response->json()),
|
||||
'total' => (int) $response->header('x-total', 0),
|
||||
];
|
||||
}
|
||||
|
||||
function generateGitlabCloneToken(GitlabApp $source): string
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
return $source->access_token;
|
||||
}
|
||||
|
||||
function loadGitlabRepositories(GitlabApp $source, int $page = 1): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
$response = Http::GitLab($apiUrl, $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/projects', [
|
||||
'membership' => 'true',
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
'order_by' => 'name',
|
||||
'sort' => 'asc',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return ['total_count' => 0, 'has_more' => false, 'repositories' => []];
|
||||
}
|
||||
|
||||
$projects = collect($response->json());
|
||||
$rawCount = count($response->json());
|
||||
$hasMore = $rawCount === 100;
|
||||
|
||||
$groupName = $source->group_name;
|
||||
if (! empty($groupName)) {
|
||||
$groups = collect(explode(',', $groupName))->map(fn ($g) => strtolower(trim($g)))->filter();
|
||||
$projects = $projects->filter(function ($project) use ($groups) {
|
||||
$namespacePath = strtolower(data_get($project, 'namespace.full_path', ''));
|
||||
|
||||
return $groups->contains(fn ($group) => $namespacePath === $group || str_starts_with($namespacePath, $group.'/'));
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => $projects->count(),
|
||||
'has_more' => $hasMore,
|
||||
'repositories' => $projects->map(fn ($project) => [
|
||||
'id' => data_get($project, 'id'),
|
||||
'name' => data_get($project, 'name'),
|
||||
'path_with_namespace' => data_get($project, 'path_with_namespace'),
|
||||
'default_branch' => data_get($project, 'default_branch', 'main'),
|
||||
'web_url' => data_get($project, 'web_url'),
|
||||
'namespace' => [
|
||||
'full_path' => data_get($project, 'namespace.full_path'),
|
||||
'kind' => data_get($project, 'namespace.kind'),
|
||||
],
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
function loadGitlabBranches(GitlabApp $source, int $projectId, int $page = 1): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
$response = Http::GitLab($apiUrl, $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/projects/{$projectId}/repository/branches", [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
@@ -301,6 +301,7 @@ function remove_iip($text)
|
||||
|
||||
// Git access tokens
|
||||
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
|
||||
$text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text);
|
||||
|
||||
// ANSI color codes
|
||||
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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::table('gitlab_apps', function (Blueprint $table) {
|
||||
$table->longText('client_id')->nullable()->after('app_secret');
|
||||
$table->longText('client_secret')->nullable()->after('client_id');
|
||||
$table->longText('access_token')->nullable()->after('client_secret');
|
||||
$table->longText('refresh_token')->nullable()->after('access_token');
|
||||
$table->integer('expires_at')->nullable()->after('refresh_token');
|
||||
$table->string('redirect_uri')->nullable()->after('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('gitlab_apps', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'expires_at',
|
||||
'redirect_uri',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -370,6 +370,12 @@ CREATE TABLE IF NOT EXISTS "gitlab_apps" (
|
||||
"is_public" INTEGER DEFAULT false NOT NULL,
|
||||
"app_id" INTEGER,
|
||||
"app_secret" TEXT,
|
||||
"client_id" TEXT,
|
||||
"client_secret" TEXT,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"redirect_uri" TEXT,
|
||||
"oauth_id" INTEGER,
|
||||
"group_name" TEXT,
|
||||
"public_key" TEXT,
|
||||
@@ -1898,6 +1904,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table', 315);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Open Repository
|
||||
<x-external-link />
|
||||
</a>
|
||||
@if (data_get($application, 'source.is_public') === false)
|
||||
@if (data_get($application, 'source.is_public') === false && $application->source instanceof \App\Models\GithubApp)
|
||||
<a target="_blank" class="hover:no-underline flex items-center gap-1"
|
||||
href="{{ getInstallationPath($application->source) }}">
|
||||
Open Git App
|
||||
@@ -67,7 +67,7 @@
|
||||
@forelse ($sources as $source)
|
||||
<div wire:key="{{ $source->name }}">
|
||||
<x-modal-confirmation title="Change Git Source" :actions="['Change git source to ' . $source->name]" :buttonFullWidth="true"
|
||||
:isHighlightedButton="$application->source_id === $source->id" :disabled="$application->source_id === $source->id"
|
||||
:isHighlightedButton="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()" :disabled="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()"
|
||||
submitAction="changeSource({{ $source->id }}, {{ $source->getMorphClass() }})"
|
||||
:confirmWithText="true" confirmationText="Change Git Source"
|
||||
confirmationLabel="Please confirm changing the git source by entering the text below"
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="box-title">
|
||||
{{ $source->name }}
|
||||
@if ($application->source_id === $source->id)
|
||||
@if ($application->source_id === $source->id && $application->source_type === $source->getMorphClass())
|
||||
<span class="text-xs">(current)</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<div>
|
||||
<div class="flex items-end gap-2">
|
||||
<h1>Create a new Application</h1>
|
||||
<x-modal-input buttonTitle="+ Add GitLab App" title="New GitLab App" closeOutside="false">
|
||||
<livewire:source.gitlab.create />
|
||||
</x-modal-input>
|
||||
@if ($repositories->count() > 0 && $gitlab_app_id)
|
||||
<x-forms.button wire:click.prevent="loadRepositories({{ $gitlab_app_id }})">
|
||||
Refresh Repository List
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="pb-4">Deploy any public or private Git repositories through a GitLab App.</div>
|
||||
@if ($gitlab_apps->count() !== 0)
|
||||
<div class="flex flex-col gap-2">
|
||||
@if ($current_step === 'gitlab_apps')
|
||||
<h2 class="pt-4 pb-4">Select a GitLab App</h2>
|
||||
<div class="flex flex-col justify-center gap-2 text-left">
|
||||
@foreach ($gitlab_apps as $glapp)
|
||||
<div class="flex">
|
||||
<div class="w-full gap-2 py-4 group coolbox"
|
||||
wire:click.prevent="loadRepositories({{ $glapp->id }})"
|
||||
wire:key="{{ $glapp->id }}">
|
||||
<div class="flex mr-4">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
{{ data_get($glapp, 'name') }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ data_get($glapp, 'html_url') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<x-loading wire:loading wire:target="loadRepositories({{ $glapp->id }})" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if ($current_step === 'repository')
|
||||
@if ($repositories->count() > 0)
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.datalist class="w-full" label="Repository" placeholder="Search repositories..." wire:model.live="selected_project_id">
|
||||
@foreach ($repositories as $repo)
|
||||
<option value="{{ data_get($repo, 'id') }}">{{ data_get($repo, 'path_with_namespace') }}</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches" wire:target="loadBranches, selected_project_id">
|
||||
Load Repository
|
||||
<x-loading-on-button wire:loading.delay wire:target="loadBranches, selected_project_id" />
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<div>No repositories found. Check your GitLab App configuration.</div>
|
||||
@endif
|
||||
@if ($branches->count() > 0)
|
||||
<h2 class="text-lg font-bold">Configuration</h2>
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<form class="flex flex-col" wire:submit='submit'>
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select id="selected_branch_name" label="Branch">
|
||||
<option value="default" disabled selected>Select a branch</option>
|
||||
@foreach ($branches as $branch)
|
||||
@if ($loop->first)
|
||||
<option selected value="{{ data_get($branch, 'name') }}">
|
||||
{{ data_get($branch, 'name') }}
|
||||
</option>
|
||||
@else
|
||||
<option value="{{ data_get($branch, 'name') }}">
|
||||
{{ data_get($branch, 'name') }}
|
||||
</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
</x-forms.select>
|
||||
@if ($is_static)
|
||||
<x-forms.input id="publish_directory" label="Publish Directory"
|
||||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
composeLocation: '{{ $docker_compose_location }}',
|
||||
normalizePath(path) {
|
||||
if (!path || path.trim() === '') return '/';
|
||||
path = path.trim();
|
||||
path = path.replace(/\/+$/, '');
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
normalizeBaseDir() {
|
||||
this.baseDir = this.normalizePath(this.baseDir);
|
||||
},
|
||||
normalizeComposeLocation() {
|
||||
this.composeLocation = this.normalizePath(this.composeLocation);
|
||||
}
|
||||
}" class="gap-2 flex flex-col">
|
||||
<x-forms.input placeholder="/" wire:model.defer="base_directory"
|
||||
label="Base Directory"
|
||||
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
|
||||
@blur="normalizeBaseDir()" />
|
||||
<x-forms.input placeholder="/docker-compose.yaml"
|
||||
wire:model.defer="docker_compose_location" label="Docker Compose Location"
|
||||
helper="It is calculated together with the Base Directory."
|
||||
x-model="composeLocation" @blur="normalizeComposeLocation()" />
|
||||
<div class="pt-2">
|
||||
<span>
|
||||
Compose file location in your repository: </span><span
|
||||
class='dark:text-warning'
|
||||
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.input wire:model="base_directory" label="Base Directory"
|
||||
helper="Directory to use as root. Useful for monorepos." />
|
||||
@endif
|
||||
@if ($show_is_static)
|
||||
<x-forms.input type="number" id="port" label="Port" :readonly="$is_static || $build_pack === 'static'"
|
||||
helper="The port your application listens on." />
|
||||
<div class="w-52">
|
||||
<x-forms.checkbox instantSave id="is_static" label="Is it a static site?"
|
||||
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.button type="submit">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="hero">
|
||||
No connected GitLab Application found. Please <a href="/sources" class="underline">add a GitLab App</a> first.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -6,6 +6,8 @@
|
||||
<livewire:project.new.public-git-repository :type="$type" />
|
||||
@elseif ($type === 'private-gh-app')
|
||||
<livewire:project.new.github-private-repository :type="$type" />
|
||||
@elseif ($type === 'private-gitlab-app')
|
||||
<livewire:project.new.gitlab-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,175 @@
|
||||
<div>
|
||||
@if ($isConnected)
|
||||
<form wire:submit='submit'>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<h1>GitLab App</h1>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.button canGate="update" :canResource="$gitlab_app" type="submit">Save</x-forms.button>
|
||||
<x-forms.button wire:click.prevent="testConnection">Test Connection</x-forms.button>
|
||||
@can('delete', $gitlab_app)
|
||||
<x-modal-confirmation title="Confirm GitLab App Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['The selected GitLab App will be permanently deleted.']"
|
||||
confirmationText="{{ data_get($gitlab_app, 'name') }}"
|
||||
confirmationLabel="Please confirm by entering the GitLab App Name below"
|
||||
shortConfirmationLabel="GitLab App Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtitle">Your GitLab App for private repositories.</div>
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 text-xs font-medium rounded bg-success/10 text-success">
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3" /></svg>
|
||||
Connected
|
||||
</span>
|
||||
<x-forms.button wire:click.prevent="disconnect"
|
||||
class="bg-transparent border-transparent hover:bg-transparent hover:border-transparent hover:underline text-xs">
|
||||
Disconnect
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="name" label="Name" />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="htmlUrl" label="GitLab URL" />
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="apiUrl" label="API URL" />
|
||||
</div>
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="groupName" label="Group Name"
|
||||
helper="Comma-separated group names to filter visible repositories." />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="customUser" label="SSH User" />
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" type="number" id="customPort" label="SSH Port" />
|
||||
</div>
|
||||
@if (!isCloud())
|
||||
<div class="w-48">
|
||||
<x-forms.checkbox canGate="update" :canResource="$gitlab_app" label="System Wide?"
|
||||
helper="If checked, this GitLab App will be available for everyone in this Coolify instance."
|
||||
instantSave id="isSystemWide" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<h3 class="pt-4">OAuth Credentials</h3>
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="clientId" label="Application ID" />
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="clientSecretInput" label="Application Secret" type="password" />
|
||||
|
||||
<h3 class="pt-4">Webhook</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm">
|
||||
Configure this webhook URL in your GitLab project settings
|
||||
(<code>Settings > Webhooks</code>):
|
||||
</div>
|
||||
<x-forms.input readonly label="Webhook URL"
|
||||
value="{{ $webhook_endpoint }}/webhooks/source/gitlab/events" />
|
||||
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token"
|
||||
helper="Set this same token in your GitLab webhook's 'Secret token' field." />
|
||||
</div>
|
||||
|
||||
<h3 class="pt-4">SSH Key (Optional)</h3>
|
||||
<div class="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Only needed if you prefer SSH-based git clone over HTTPS OAuth token.
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select canGate="update" :canResource="$gitlab_app" id="privateKeyId" label="Private Key">
|
||||
<option value="">None</option>
|
||||
@foreach ($privateKeys as $key)
|
||||
<option value="{{ $key->id }}">{{ $key->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
@if ($applications->count() > 0)
|
||||
<h3 class="pt-4">Applications Using This Source</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($applications as $application)
|
||||
<a class="coolbox group"
|
||||
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'),
|
||||
]) }}">
|
||||
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
|
||||
<div class="box-title">{{ $application->name }}</div>
|
||||
<div class="box-description">{{ $application->git_repository }}:{{ $application->git_branch }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
<h1>GitLab App</h1>
|
||||
<div class="flex gap-2">
|
||||
@can('delete', $gitlab_app)
|
||||
<x-modal-confirmation title="Confirm GitLab App Deletion?" isErrorButton buttonTitle="Delete"
|
||||
submitAction="delete" :actions="['The selected GitLab App will be permanently deleted.']"
|
||||
confirmationText="{{ data_get($gitlab_app, 'name') }}"
|
||||
confirmationLabel="Please confirm by entering the GitLab App Name below"
|
||||
shortConfirmationLabel="GitLab App Name" :confirmWithPassword="false"
|
||||
step2ButtonText="Permanently Delete" />
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtitle">Connect your GitLab instance to deploy private repositories.</div>
|
||||
|
||||
<div class="mb-6 rounded-sm alert-warning">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6 stroke-current shrink-0" fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>Complete the setup below to connect this GitLab source.</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<h3>Step 1: Create an OAuth Application on GitLab</h3>
|
||||
<div class="text-sm flex flex-col gap-1">
|
||||
<p>Go to your GitLab instance and create a new OAuth Application:</p>
|
||||
<a href="{{ rtrim($htmlUrl, '/') }}/-/profile/applications" target="_blank"
|
||||
class="inline-flex items-center gap-1 text-sm underline">
|
||||
{{ rtrim($htmlUrl, '/') }}/-/profile/applications
|
||||
<x-external-link />
|
||||
</a>
|
||||
<ul class="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Set <strong>Redirect URI</strong> to: <code>{{ $redirectUri }}</code></li>
|
||||
<li>Enable scopes: <code>api</code>, <code>read_user</code>, <code>read_repository</code></li>
|
||||
<li>Uncheck <strong>Confidential</strong> if you run into issues</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3 class="pt-2">Step 2: Enter the credentials</h3>
|
||||
<form wire:submit='submit' class="flex flex-col gap-2">
|
||||
<x-forms.input id="name" label="Name" />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input id="htmlUrl" label="GitLab URL" />
|
||||
<x-forms.input id="apiUrl" label="API URL" />
|
||||
</div>
|
||||
<x-forms.input id="clientId" label="Application ID" required
|
||||
helper="The Application ID from your GitLab OAuth Application." />
|
||||
<x-forms.input id="clientSecretInput" label="Application Secret" type="password" required
|
||||
helper="The Secret from your GitLab OAuth Application." />
|
||||
<x-forms.input id="groupName" label="Group Name"
|
||||
helper="Optional. Comma-separated group names to filter repositories." />
|
||||
<div class="flex gap-2">
|
||||
<x-forms.input id="customUser" label="SSH User" />
|
||||
<x-forms.input type="number" id="customPort" label="SSH Port" />
|
||||
</div>
|
||||
@if (!isCloud())
|
||||
<x-forms.checkbox label="System Wide?" id="isSystemWide"
|
||||
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
|
||||
@endif
|
||||
<x-forms.button type="submit" class="mt-2">Save Credentials</x-forms.button>
|
||||
</form>
|
||||
|
||||
@if ($clientId)
|
||||
<h3 class="pt-2">Step 3: Authorize with GitLab</h3>
|
||||
<div class="text-sm">Click the button below to authorize Coolify with your GitLab instance.</div>
|
||||
<a href="{{ $this->getOAuthUrl() }}" class="w-fit">
|
||||
<x-forms.button class="mt-2">
|
||||
Connect to GitLab
|
||||
</x-forms.button>
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
<div>
|
||||
<form class="flex flex-col gap-2" wire:submit='createGitLabApp'>
|
||||
<div class="flex gap-2">
|
||||
<h2>New GitLab App</h2>
|
||||
<x-forms.button type="submit">Save</x-forms.button>
|
||||
</div>
|
||||
<div class="subtitle">Add a self-hosted or GitLab.com instance as a source for your applications.</div>
|
||||
<x-forms.input id="name" label="Name" required />
|
||||
<x-forms.input id="html_url" label="GitLab URL" required
|
||||
helper="For self-hosted GitLab, enter your instance URL (e.g., https://gitlab.example.com)." />
|
||||
<x-forms.input id="group_name" label="Group Name"
|
||||
helper="Optional. Comma-separated group names to filter repositories (e.g., myorg,myteam)." />
|
||||
@if (!isCloud())
|
||||
<x-forms.checkbox label="System Wide?" id="is_system_wide"
|
||||
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
|
||||
@endif
|
||||
</form>
|
||||
</div>
|
||||
@@ -5,9 +5,12 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<h1>Sources</h1>
|
||||
@can('createAnyResource')
|
||||
<x-modal-input buttonTitle="+ Add" title="New GitHub App" :closeOutside="false">
|
||||
<x-modal-input buttonTitle="+ Add GitHub" title="New GitHub App" :closeOutside="false">
|
||||
<livewire:source.github.create />
|
||||
</x-modal-input>
|
||||
<x-modal-input buttonTitle="+ Add GitLab" title="New GitLab App" :closeOutside="false">
|
||||
<livewire:source.gitlab.create />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</div>
|
||||
<div class="subtitle">Git sources for your applications.</div>
|
||||
@@ -17,7 +20,6 @@
|
||||
<a class="flex gap-2 text-center hover:no-underline coolbox group"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('source.github.show', ['github_app_uuid' => data_get($source, 'uuid')]) }}">
|
||||
{{-- <x-git-icon class="dark:text-white w-8 h-8 mt-1" git="{{ $source->getMorphClass() }}" /> --}}
|
||||
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
|
||||
<div class="box-title">{{ $source->name }}</div>
|
||||
@if (is_null($source->app_id))
|
||||
@@ -29,6 +31,22 @@
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@elseif ($source->getMorphClass() === 'App\Models\GitlabApp')
|
||||
<a class="flex gap-2 text-center hover:no-underline coolbox group"
|
||||
{{ wireNavigate() }}
|
||||
href="{{ route('source.gitlab.show', ['gitlab_app_uuid' => data_get($source, 'uuid')]) }}">
|
||||
<div class="text-left dark:group-hover:text-white flex flex-col justify-center mx-6">
|
||||
<div class="box-title">
|
||||
<x-git-icon class="inline-block w-4 h-4 mr-1" git="App\Models\GitlabApp" />
|
||||
{{ $source->name }}
|
||||
</div>
|
||||
@if ($source->isConnected())
|
||||
<span class="box-description text-success">Connected — {{ $source->html_url }}</span>
|
||||
@else
|
||||
<span class="box-description text-warning">Setup required — {{ $source->html_url }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</a>
|
||||
@endif
|
||||
@empty
|
||||
<div>
|
||||
|
||||
@@ -83,6 +83,7 @@ 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\Github\Change as GitHubChange;
|
||||
use App\Livewire\Source\Gitlab\Change as GitLabChange;
|
||||
use App\Livewire\Storage\Index as StorageIndex;
|
||||
use App\Livewire\Storage\Show as StorageShow;
|
||||
use App\Livewire\Subscription\Index as SubscriptionIndex;
|
||||
@@ -353,6 +354,7 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show');
|
||||
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::middleware(['auth'])->group(function () {
|
||||
|
||||
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::middleware(['web', 'auth', 'throttle:30,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::post('/source/github/events', [Github::class, 'normal']);
|
||||
Route::post('/source/github/events/manual', [Github::class, 'manual']);
|
||||
|
||||
Route::post('/source/gitlab/events', [Gitlab::class, 'normal']);
|
||||
Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']);
|
||||
|
||||
Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Application\Source;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::find(0)) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
test('a GitLab source is not hidden by a GitHub source sharing the same numeric id', function () {
|
||||
$githubApp = GithubApp::create([
|
||||
'name' => 'gh',
|
||||
'team_id' => $this->team->id,
|
||||
'api_url' => 'https://api.github.com',
|
||||
'html_url' => 'https://github.com',
|
||||
'is_public' => false,
|
||||
'app_id' => 123,
|
||||
]);
|
||||
|
||||
$gitlabApp = GitlabApp::create([
|
||||
'name' => 'gl',
|
||||
'team_id' => $this->team->id,
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'is_public' => false,
|
||||
'access_token' => 'token',
|
||||
'refresh_token' => 'refresh',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
// The two source tables auto-increment independently, so the first row in each shares id 1.
|
||||
expect($gitlabApp->id)->toBe($githubApp->id);
|
||||
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'private_key_id' => null,
|
||||
'source_id' => $githubApp->id,
|
||||
'source_type' => GithubApp::class,
|
||||
]);
|
||||
|
||||
$component = Livewire::test(Source::class, ['application' => $application]);
|
||||
$sources = $component->get('sources');
|
||||
|
||||
expect($sources->contains(fn ($s) => $s instanceof GitlabApp && $s->id === $gitlabApp->id))->toBeTrue();
|
||||
expect($sources->contains(fn ($s) => $s instanceof GithubApp && $s->id === $githubApp->id))->toBeFalse();
|
||||
|
||||
// The GitLab source renders as selectable, not flagged as the current GitHub source despite the shared id.
|
||||
$component->assertSee('gl')->assertDontSee('(current)');
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Source\Gitlab\Change as GitlabSource;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
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]);
|
||||
|
||||
$this->gitlabApp = GitlabApp::create([
|
||||
'name' => 'Self-hosted GitLab',
|
||||
'api_url' => 'https://gitlab.example.com/api/v4',
|
||||
'html_url' => 'https://gitlab.example.com',
|
||||
'client_id' => 'client-id',
|
||||
'client_secret' => 'client-secret',
|
||||
'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect',
|
||||
'team_id' => $this->team->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
describe('GitLab OAuth callback state validation', function () {
|
||||
test('rejects a callback whose state is the source UUID (the old attack vector)', function () {
|
||||
Http::fake();
|
||||
|
||||
$response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$this->gitlabApp->uuid);
|
||||
|
||||
$response->assertRedirect(route('source.all'));
|
||||
Http::assertNothingSent();
|
||||
expect($this->gitlabApp->refresh()->access_token)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a callback with an unknown / expired state and never exchanges the code', function () {
|
||||
Http::fake();
|
||||
|
||||
$response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=not-a-real-state');
|
||||
|
||||
$response->assertRedirect(route('source.all'));
|
||||
Http::assertNothingSent();
|
||||
expect($this->gitlabApp->refresh()->access_token)->toBeNull();
|
||||
});
|
||||
|
||||
test('rejects a state issued for a different team', function () {
|
||||
Http::fake();
|
||||
$otherTeam = Team::factory()->create();
|
||||
$state = 'state-for-other-team';
|
||||
Cache::put(GitlabSource::oauthStateCacheKey($state), [
|
||||
'gitlab_app_id' => $this->gitlabApp->id,
|
||||
'team_id' => $otherTeam->id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
$response = $this->get('/webhooks/source/gitlab/redirect?code=any&state='.$state);
|
||||
|
||||
$response->assertRedirect(route('source.all'));
|
||||
Http::assertNothingSent();
|
||||
expect($this->gitlabApp->refresh()->access_token)->toBeNull();
|
||||
});
|
||||
|
||||
test('accepts a valid one-time state, exchanges the code, and consumes the state', function () {
|
||||
Http::fake([
|
||||
'*/oauth/token' => Http::response([
|
||||
'access_token' => 'new-access-token',
|
||||
'refresh_token' => 'new-refresh-token',
|
||||
'expires_in' => 7200,
|
||||
]),
|
||||
]);
|
||||
|
||||
$state = 'a-valid-server-issued-state';
|
||||
$key = GitlabSource::oauthStateCacheKey($state);
|
||||
Cache::put($key, [
|
||||
'gitlab_app_id' => $this->gitlabApp->id,
|
||||
'team_id' => $this->team->id,
|
||||
], now()->addMinutes(60));
|
||||
|
||||
$response = $this->get('/webhooks/source/gitlab/redirect?code=valid-code&state='.$state);
|
||||
|
||||
$response->assertRedirect(route('source.gitlab.show', ['gitlab_app_uuid' => $this->gitlabApp->uuid]));
|
||||
|
||||
$fresh = $this->gitlabApp->refresh();
|
||||
$fresh->makeVisible(['access_token', 'refresh_token']);
|
||||
expect($fresh->access_token)->toBe('new-access-token');
|
||||
expect($fresh->refresh_token)->toBe('new-refresh-token');
|
||||
|
||||
// State must be single-use.
|
||||
expect(Cache::get($key))->toBeNull();
|
||||
});
|
||||
|
||||
test('requires authentication', function () {
|
||||
auth()->logout();
|
||||
session()->forget('currentTeam');
|
||||
|
||||
$response = $this->get('/webhooks/source/gitlab/redirect?code=any&state=any');
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
it('returns a stable shape with has_more false when GitLab repo listing fails', function () {
|
||||
Http::fake(['*' => Http::response(['message' => '401 Unauthorized'], 401)]);
|
||||
|
||||
$source = new GitlabApp([
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$result = loadGitlabRepositories($source);
|
||||
|
||||
expect($result)->toHaveKeys(['total_count', 'has_more', 'repositories']);
|
||||
expect($result['has_more'])->toBeFalse();
|
||||
expect($result['repositories'])->toBe([]);
|
||||
});
|
||||
|
||||
it('limits GitLab repositories to the exact group and its descendants', function () {
|
||||
Http::fake(['*' => Http::response([
|
||||
['id' => 1, 'name' => 'a', 'path_with_namespace' => 'team/a', 'namespace' => ['full_path' => 'team', 'kind' => 'group']],
|
||||
['id' => 2, 'name' => 'b', 'path_with_namespace' => 'team/sub/b', 'namespace' => ['full_path' => 'team/sub', 'kind' => 'group']],
|
||||
['id' => 3, 'name' => 'c', 'path_with_namespace' => 'team-secret/c', 'namespace' => ['full_path' => 'team-secret', 'kind' => 'group']],
|
||||
], 200)]);
|
||||
|
||||
$source = new GitlabApp([
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
'expires_at' => time() + 3600,
|
||||
'group_name' => 'team',
|
||||
]);
|
||||
|
||||
$paths = collect(loadGitlabRepositories($source)['repositories'])->pluck('path_with_namespace')->all();
|
||||
|
||||
expect($paths)->toContain('team/a', 'team/sub/b');
|
||||
expect($paths)->not->toContain('team-secret/c');
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
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]);
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam resolves a system-wide GitLab source owned by another team', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
$systemWide = GitlabApp::create([
|
||||
'name' => 'Shared GitLab',
|
||||
'api_url' => 'https://gitlab.example.com/api/v4',
|
||||
'html_url' => 'https://gitlab.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_system_wide' => true,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
// Mirrors Source::changeSource() resolution; before the fix this returned null for system-wide sources (404).
|
||||
$resolved = GitlabApp::ownedByCurrentTeam()->find($systemWide->id);
|
||||
|
||||
expect($resolved)->not->toBeNull();
|
||||
expect($resolved->id)->toBe($systemWide->id);
|
||||
});
|
||||
|
||||
test('ownedByCurrentTeam still excludes other teams private (non system-wide) sources', function () {
|
||||
$otherTeam = Team::factory()->create();
|
||||
|
||||
$foreign = GitlabApp::create([
|
||||
'name' => 'Foreign GitLab',
|
||||
'api_url' => 'https://gitlab.example.com/api/v4',
|
||||
'html_url' => 'https://gitlab.example.com',
|
||||
'team_id' => $otherTeam->id,
|
||||
'is_system_wide' => false,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
expect(GitlabApp::ownedByCurrentTeam()->find($foreign->id))->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
test('connected gitlab oauth submodule credentials use per command git config', function () {
|
||||
$application = new Application;
|
||||
$application->forceFill([
|
||||
'uuid' => 'test-app-uuid',
|
||||
'git_repository' => 'group/private-app',
|
||||
'git_branch' => 'main',
|
||||
'git_commit_sha' => 'HEAD',
|
||||
]);
|
||||
|
||||
$settings = new ApplicationSetting;
|
||||
$settings->is_git_shallow_clone_enabled = false;
|
||||
$settings->is_git_submodules_enabled = true;
|
||||
$settings->is_git_lfs_enabled = false;
|
||||
$application->setRelation('settings', $settings);
|
||||
|
||||
$source = new GitlabApp;
|
||||
$source->forceFill([
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'api_url' => 'https://gitlab.example.test/api/v4',
|
||||
'is_public' => false,
|
||||
]);
|
||||
// A non-expired token short-circuits refreshGitlabToken(), so no HTTP call is made.
|
||||
$source->access_token = 'gl-token/with+sym';
|
||||
$source->refresh_token = 'gl-refresh-token';
|
||||
$source->expires_at = time() + 3600;
|
||||
$application->setRelation('source', $source);
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: 'test-deployment',
|
||||
exec_in_docker: false,
|
||||
);
|
||||
|
||||
$expectedConfig = "git -c 'url.https://oauth2:gl-token%2Fwith%2Bsym@gitlab.example.test/.insteadOf=https://gitlab.example.test/' -c http.version=HTTP/1.1";
|
||||
|
||||
expect($result['commands'])
|
||||
->not->toContain('git config --global')
|
||||
->toContain("{$expectedConfig} clone --recurse-submodules -b 'main'")
|
||||
->toContain("{$expectedConfig} submodule sync")
|
||||
->toContain("{$expectedConfig} submodule update --init --recursive");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
it('returns api base url with /api/v4 appended when missing', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('returns api base url unchanged when /api/v4 already present', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('strips trailing slash from api url base', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('reports connected when tokens are present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
]);
|
||||
expect($app->isConnected())->toBeTrue();
|
||||
});
|
||||
|
||||
it('reports not connected when tokens are missing', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
|
||||
it('reports not connected when only access token is present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
it('redacts oauth2 tokens from deployment logs', function () {
|
||||
$fakeOAuthToken = str_repeat('a', 30); // ggignore
|
||||
$text = "git clone https://oauth2:{$fakeOAuthToken}@gitlab.example.com/group/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeOAuthToken);
|
||||
expect($result)->toContain('oauth2:');
|
||||
});
|
||||
|
||||
it('redacts x-access-token from logs', function () {
|
||||
$fakeToken = str_repeat('x', 40); // ggignore
|
||||
$text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
expect($result)->toContain('x-access-token:');
|
||||
});
|
||||
|
||||
it('redacts gitlab personal access tokens', function () {
|
||||
$fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore
|
||||
$text = "Authorization: Bearer {$fakeToken}";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
});
|
||||
@@ -3,9 +3,16 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\GitlabApp;
|
||||
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);
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
@@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function (
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () {
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () {
|
||||
expect($lsRemoteResult)->not->toBeNull();
|
||||
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
|
||||
});
|
||||
|
||||
it('preserves custom GitLab http port for connected OAuth sources', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'http://gitlab.example.test:8081',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
|
||||
expect($result['fullRepoUrl'])
|
||||
->toContain('gitlab.example.test:8081')
|
||||
->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git');
|
||||
expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git');
|
||||
});
|
||||
|
||||
it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$settings = (object) [
|
||||
'is_git_shallow_clone_enabled' => false,
|
||||
'is_git_submodules_enabled' => true,
|
||||
];
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: $deploymentUuid,
|
||||
pull_request_id: 2,
|
||||
exec_in_docker: false,
|
||||
only_checkout: true,
|
||||
custom_base_dir: '/artifacts/test',
|
||||
);
|
||||
|
||||
$commands = $result['commands'];
|
||||
// The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail.
|
||||
expect($commands)
|
||||
->toContain('oauth2:gitlab-access-token@gitlab.example.test')
|
||||
->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'")
|
||||
->toContain('http.version=HTTP/1.1 submodule update --init --recursive');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user