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
2026-07-20 23:21:40 +02:00
committed by Andras Bacsai
parent 9d341d0bb9
commit a26091de0a
32 changed files with 2216 additions and 26 deletions
+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,
+1 -2
View File
@@ -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');
+162
View File
@@ -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();
}
+1
View File
@@ -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',
]);
});
}
};
+7
View File
@@ -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 &gt; 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>
+20 -2
View File
@@ -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>
+2
View File
@@ -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 () {
+2
View File
@@ -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");
});
+52
View File
@@ -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();
});
+27
View File
@@ -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);
});
+86
View File
@@ -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');
});