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:
Mike Chong authored and Andras Bacsai committed 2026-07-20 23:21:40 +02:00
1 parent 9d341d0bb9
commit a26091de0a
32 files changed
+2216 -26

No files matched your search

+300 -2
View File
@@ -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([
+28 -11
View File
@@ -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!');
}
}
+6
View File
@@ -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)',
+278
View File
@@ -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}";
}
}
+62
View File
@@ -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);
}
}
}
+83 -5
View File
@@ -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);
+70 -1
View File
@@ -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;
}
}
+52
View File
@@ -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;
}
}
+11
View File
@@ -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;
});
}
}
+5
View File
@@ -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,