feat: GitLab App source (#10538)

This commit is contained in:
Andras Bacsai
2026-07-21 22:22:10 +02:00
committed by GitHub
44 changed files with 4077 additions and 34 deletions
@@ -0,0 +1,540 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\GitlabApp;
use App\Rules\SafeExternalUrl;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use OpenApi\Attributes as OA;
class GitlabController extends Controller
{
private function removeSensitiveData(GitlabApp $gitlabApp)
{
if (request()->attributes->get('can_read_sensitive', false) === true) {
$gitlabApp->makeVisible([
'client_secret',
'webhook_token',
'access_token',
'refresh_token',
]);
} else {
$gitlabApp->makeHidden([
'client_secret',
'webhook_token',
'access_token',
'refresh_token',
]);
}
return serializeApiResponse($gitlabApp);
}
private function findTeamGitlabApp(int|string $gitlabAppId, int $teamId): GitlabApp
{
return GitlabApp::where('id', $gitlabAppId)
->where('team_id', $teamId)
->firstOrFail();
}
private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string
{
return rtrim($htmlUrl, '/').'/api/v4';
}
#[OA\Get(
summary: 'List',
description: 'List all GitLab apps for the current team (and system-wide sources).',
path: '/gitlab-apps',
operationId: 'list-gitlab-apps',
security: [
['bearerAuth' => []],
],
tags: ['GitLab Apps'],
responses: [
new OA\Response(
response: 200,
description: 'List of GitLab apps.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'client_id' => ['type' => 'string', 'nullable' => true],
'group_name' => ['type' => 'string', 'nullable' => true],
'redirect_uri' => ['type' => 'string', 'nullable' => true],
'is_system_wide' => ['type' => 'boolean'],
'is_public' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
]
)
)
),
]
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function list_gitlab_apps(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$gitlabApps = GitlabApp::where(function ($query) use ($teamId) {
$query->where('team_id', $teamId)
->orWhere('is_system_wide', true);
})->get();
$gitlabApps = $gitlabApps->map(function ($app) {
return $this->removeSensitiveData($app);
});
return response()->json($gitlabApps);
}
#[OA\Post(
summary: 'Create GitLab App',
description: 'Create a new GitLab app (OAuth source). Credentials may be supplied later via the UI or update endpoint.',
path: '/gitlab-apps',
operationId: 'create-gitlab-app',
security: [
['bearerAuth' => []],
],
tags: ['GitLab Apps'],
requestBody: new OA\RequestBody(
description: 'GitLab app creation payload.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'Name of the GitLab app.'],
'html_url' => ['type' => 'string', 'description' => 'GitLab instance URL (e.g., https://gitlab.com).'],
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL (defaults to {html_url}/api/v4).'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional comma-separated group names to filter repositories.'],
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application ID.'],
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'GitLab OAuth Application Secret.'],
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token (auto-generated when omitted).'],
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI registered in GitLab.'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (non-cloud instances only).'],
],
required: ['name', 'html_url'],
),
),
],
),
responses: [
new OA\Response(
response: 201,
description: 'GitLab app created successfully.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'id' => ['type' => 'integer'],
'uuid' => ['type' => 'string'],
'name' => ['type' => 'string'],
'api_url' => ['type' => 'string'],
'html_url' => ['type' => 'string'],
'custom_user' => ['type' => 'string'],
'custom_port' => ['type' => 'integer'],
'client_id' => ['type' => 'string', 'nullable' => true],
'group_name' => ['type' => 'string', 'nullable' => true],
'redirect_uri' => ['type' => 'string', 'nullable' => true],
'is_system_wide' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
]
)
),
]
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 422,
ref: '#/components/responses/422',
),
]
)]
public function create_gitlab_app(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
$this->authorize('create', [GitlabApp::class]);
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = [
'name',
'html_url',
'api_url',
'custom_user',
'custom_port',
'group_name',
'client_id',
'client_secret',
'webhook_token',
'redirect_uri',
'is_system_wide',
];
$validator = customApiValidator($request->all(), [
'name' => 'required|string|max:255',
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'api_url' => ['nullable', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'nullable|string|max:255',
'custom_port' => 'nullable|integer|min:1|max:65535',
'group_name' => 'nullable|string|max:255',
'client_id' => 'nullable|string|max:255',
'client_secret' => 'nullable|string',
'webhook_token' => 'nullable|string',
// Callback to this Coolify instance — may be a private/LAN URL; do not use SafeExternalUrl.
'redirect_uri' => ['nullable', 'string', 'url'],
'is_system_wide' => 'boolean',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
try {
$htmlUrl = rtrim((string) $request->input('html_url'), '/');
$apiUrl = filled($request->input('api_url'))
? rtrim((string) $request->input('api_url'), '/')
: $this->gitlabApiUrlFromHtmlUrl($htmlUrl);
$payload = [
'name' => $request->input('name'),
'html_url' => $htmlUrl,
'api_url' => $apiUrl,
'custom_user' => $request->input('custom_user', 'git'),
'custom_port' => $request->input('custom_port', 22),
'group_name' => $request->input('group_name'),
'client_id' => $request->input('client_id'),
'client_secret' => $request->input('client_secret'),
'webhook_token' => $request->input('webhook_token') ?: Str::random(32),
'redirect_uri' => $request->input('redirect_uri'),
'is_public' => false,
'team_id' => $teamId,
];
if (! isCloud()) {
$payload['is_system_wide'] = $request->boolean('is_system_wide', false);
}
$gitlabApp = GitlabApp::create($payload);
auditLog('api.gitlab_app.created', [
'team_id' => $teamId,
'gitlab_app_uuid' => $gitlabApp->uuid,
'gitlab_app_name' => $gitlabApp->name,
]);
return response()->json($this->removeSensitiveData($gitlabApp->fresh()), 201);
} catch (\Throwable $e) {
return handleError($e);
}
}
#[OA\Patch(
path: '/gitlab-apps/{gitlab_app_id}',
operationId: 'updateGitlabApp',
security: [
['bearerAuth' => []],
],
tags: ['GitLab Apps'],
summary: 'Update GitLab App',
description: 'Update an existing GitLab app.',
parameters: [
new OA\Parameter(
name: 'gitlab_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitLab App ID'
),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'GitLab App name'],
'html_url' => ['type' => 'string', 'description' => 'GitLab HTML URL'],
'api_url' => ['type' => 'string', 'description' => 'GitLab API URL'],
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
'group_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Optional group filter'],
'client_id' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application ID'],
'client_secret' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth Application Secret'],
'webhook_token' => ['type' => 'string', 'nullable' => true, 'description' => 'Webhook secret token'],
'redirect_uri' => ['type' => 'string', 'nullable' => true, 'description' => 'OAuth redirect URI'],
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
]
)
)
),
responses: [
new OA\Response(
response: 200,
description: 'GitLab app updated successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitLab app updated successfully'],
'data' => ['type' => 'object', 'description' => 'Updated GitLab app data'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitLab app not found'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function update_gitlab_app(Request $request, $gitlab_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
$this->authorize('update', $gitlabApp);
$allowedFields = [
'name',
'html_url',
'api_url',
'custom_user',
'custom_port',
'group_name',
'client_id',
'client_secret',
'webhook_token',
'redirect_uri',
];
if (! isCloud()) {
$allowedFields[] = 'is_system_wide';
}
$payload = $request->only($allowedFields);
$rules = [];
if (isset($payload['name'])) {
$rules['name'] = 'string|max:255';
}
if (isset($payload['html_url'])) {
$rules['html_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['api_url'])) {
$rules['api_url'] = ['url', new SafeExternalUrl];
}
if (isset($payload['custom_user'])) {
$rules['custom_user'] = 'string|max:255';
}
if (isset($payload['custom_port'])) {
$rules['custom_port'] = 'integer|min:1|max:65535';
}
if (array_key_exists('group_name', $payload)) {
$rules['group_name'] = 'nullable|string|max:255';
}
if (array_key_exists('client_id', $payload)) {
$rules['client_id'] = 'nullable|string|max:255';
}
if (array_key_exists('client_secret', $payload)) {
$rules['client_secret'] = 'nullable|string';
}
if (array_key_exists('webhook_token', $payload)) {
$rules['webhook_token'] = 'nullable|string';
}
if (array_key_exists('redirect_uri', $payload)) {
// Callback to this Coolify instance — may be a private/LAN URL.
$rules['redirect_uri'] = 'nullable|url';
}
if (! isCloud() && isset($payload['is_system_wide'])) {
$rules['is_system_wide'] = 'boolean';
}
$validator = customApiValidator($payload, $rules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation error',
'errors' => $validator->errors(),
], 422);
}
if (isset($payload['html_url'])) {
$payload['html_url'] = rtrim((string) $payload['html_url'], '/');
if (! filled($payload['api_url'] ?? null)) {
$payload['api_url'] = $this->gitlabApiUrlFromHtmlUrl($payload['html_url']);
}
}
if (isset($payload['api_url'])) {
$payload['api_url'] = rtrim((string) $payload['api_url'], '/');
}
$gitlabApp->update($payload);
auditLog('api.gitlab_app.updated', [
'team_id' => $teamId,
'gitlab_app_uuid' => $gitlabApp->uuid,
'gitlab_app_name' => $gitlabApp->name,
'changed_fields' => array_values(array_diff(array_keys($payload), ['client_secret', 'webhook_token'])),
]);
return response()->json([
'message' => 'GitLab app updated successfully',
'data' => $this->removeSensitiveData($gitlabApp->fresh()),
]);
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitLab app not found',
], 404);
}
}
#[OA\Delete(
path: '/gitlab-apps/{gitlab_app_id}',
operationId: 'deleteGitlabApp',
security: [
['bearerAuth' => []],
],
tags: ['GitLab Apps'],
summary: 'Delete GitLab App',
description: 'Delete a GitLab app if it is not being used by any applications.',
parameters: [
new OA\Parameter(
name: 'gitlab_app_id',
in: 'path',
required: true,
schema: new OA\Schema(type: 'integer'),
description: 'GitLab App ID'
),
],
responses: [
new OA\Response(
response: 200,
description: 'GitLab app deleted successfully',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'GitLab app deleted successfully'],
]
)
)
),
new OA\Response(response: 401, description: 'Unauthorized'),
new OA\Response(response: 404, description: 'GitLab app not found'),
new OA\Response(
response: 409,
description: 'Conflict - GitLab app is in use',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'This GitLab app is being used by 5 application(s). Please delete all applications first.'],
]
)
)
),
]
)]
public function delete_gitlab_app($gitlab_app_id)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
try {
$gitlabApp = $this->findTeamGitlabApp($gitlab_app_id, $teamId);
$this->authorize('delete', $gitlabApp);
if ($gitlabApp->applications->isNotEmpty()) {
$count = $gitlabApp->applications->count();
return response()->json([
'message' => "This GitLab app is being used by {$count} application(s). Please delete all applications first.",
], 409);
}
$deletedUuid = $gitlabApp->uuid;
$deletedName = $gitlabApp->name;
$gitlabApp->delete();
auditLog('api.gitlab_app.deleted', [
'team_id' => $teamId,
'gitlab_app_uuid' => $deletedUuid,
'gitlab_app_name' => $deletedName,
]);
return response()->json([
'message' => 'GitLab app deleted successfully',
]);
} catch (ModelNotFoundException $e) {
return response()->json([
'message' => 'GitLab app not found',
], 404);
}
}
}
+305 -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,307 @@ 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();
// Only users who may administer the source can complete OAuth and store tokens.
if (! $request->user()->can('update', $gitlabApp)) {
return redirect()->route('source.all')->with('error', 'You are not authorized to connect this GitLab App.');
}
$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::findByWebhookToken($x_gitlab_token);
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 +596,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,242 @@
<?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\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
class GitlabPrivateRepository extends Component
{
use AuthorizesRequests;
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 {
$this->authorize('create', Application::class);
$validator = validator([
'selected_repository_path' => $this->selected_repository_path,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
], [
'selected_repository_path' => 'required|string',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ValidationPatterns::filePathRules(),
]);
if ($validator->fails()) {
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
$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)',
+56
View File
@@ -8,6 +8,7 @@ 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 Illuminate\Validation\ValidationException;
use Livewire\Component;
@@ -79,6 +80,8 @@ class Change extends Component
public string $activeTab = 'general';
public bool $isConnected = false;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
protected function rules(): array
@@ -230,6 +233,7 @@ class Change extends Component
GithubAppPermissionJob::dispatchSync($this->github_app);
$this->github_app->refresh()->makeVisible('client_secret')->makeVisible('webhook_secret');
$this->syncData(false);
$this->isConnected = $this->github_app->isConnected();
$this->name = str($this->github_app->name)->kebab();
$this->dispatch('success', 'Github App permissions updated.');
@@ -247,6 +251,55 @@ class Change extends Component
}
}
public function testConnection()
{
try {
$this->authorize('view', $this->github_app);
if (! $this->github_app->isConnected()) {
$this->dispatch('error', 'GitHub App is not fully set up. Please complete installation first.');
return;
}
if (! $this->github_app->private_key_id || ! $this->github_app->privateKey) {
$this->dispatch('error', 'Private Key not found. Please select a valid private key.');
return;
}
$jwt = generateGithubJwt($this->github_app);
$appResponse = Http::withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])->timeout(10)->get("{$this->github_app->api_url}/app");
if (! $appResponse->successful()) {
$error = data_get($appResponse->json(), 'message', 'Unknown error');
$this->dispatch('error', "Connection failed: {$error}");
return;
}
// Confirm installation credentials can mint an installation access token.
generateGithubInstallationToken($this->github_app);
$appName = data_get($appResponse->json(), 'name')
?? data_get($appResponse->json(), 'slug', 'unknown');
$this->dispatch('success', "Connection successful! Authenticated as GitHub App: {$appName}");
} catch (\Throwable $e) {
$errorMessage = $e->getMessage();
if (str_contains($errorMessage, 'DECODER routines::unsupported') ||
str_contains($errorMessage, 'parse your key')) {
$this->dispatch('error', 'The selected private key format is not supported for GitHub Apps. <br><br>Please use an RSA private key in PEM format (BEGIN RSA PRIVATE KEY). <br><br>OpenSSH format keys (BEGIN OPENSSH PRIVATE KEY) are not supported.');
return;
}
return handleError($e, $this);
}
}
public function mount()
{
try {
@@ -260,6 +313,7 @@ class Change extends Component
// Sync data from model to properties
$this->syncData(false);
$this->isConnected = $this->github_app->isConnected();
// Override name with kebab case for display
$this->name = str($this->github_app->name)->kebab();
@@ -373,6 +427,7 @@ class Change extends Component
$this->syncData(true);
$this->github_app->save();
$this->isConnected = $this->github_app->isConnected();
$this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
@@ -404,6 +459,7 @@ class Change extends Component
$this->syncData(true);
$this->github_app->save();
$this->isConnected = $this->github_app->isConnected();
$this->dispatch('success', 'Github App updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
+359
View File
@@ -0,0 +1,359 @@
<?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\Gate;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Livewire\Component;
class Change extends Component
{
use AuthorizesRequests;
public string $webhook_endpoint = '';
public string $custom_webhook_endpoint = '';
public bool $use_custom_webhook_endpoint = false;
public ?string $ipv4 = null;
public ?string $ipv6 = null;
public ?string $fqdn = null;
public $parameters;
public ?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;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
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',
'webhook_endpoint' => ['required', 'string', 'url'],
'custom_webhook_endpoint' => ['nullable', 'string', 'url'],
'use_custom_webhook_endpoint' => ['required', 'bool'],
];
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->apiUrl)
|| $this->apiUrl === rtrim($this->htmlUrl, '/').'/api/v4';
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->apiUrl = rtrim($this->htmlUrl, '/').'/api/v4';
}
}
public function updatedWebhookEndpoint(): void
{
$this->persistRedirectUriFromEndpoint();
}
public function updatedUseCustomWebhookEndpoint(): void
{
$this->persistRedirectUriFromEndpoint();
}
public function updatedCustomWebhookEndpoint(): void
{
$this->persistRedirectUriFromEndpoint();
}
private function persistRedirectUriFromEndpoint(): void
{
$this->refreshRedirectUri();
if (! $this->gitlab_app || blank($this->redirectUri)) {
return;
}
try {
$this->authorize('update', $this->gitlab_app);
if ($this->gitlab_app->redirect_uri !== $this->redirectUri) {
$this->gitlab_app->redirect_uri = $this->redirectUri;
$this->gitlab_app->save();
}
} catch (\Throwable) {
// Keep the live redirect URI even if the user cannot persist yet.
}
}
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') ?? '';
}
// Prefer a previously saved redirect base when it matches one of the selectable endpoints
// or when it differs (restore custom mode for self-hosted / tunnel setups).
$savedRedirect = $this->gitlab_app->redirect_uri;
if (filled($savedRedirect)) {
$savedBase = rtrim(str($savedRedirect)->before('/webhooks/source/gitlab/redirect')->toString(), '/');
$known = collect([$this->fqdn, $this->ipv4, $this->ipv6, config('app.url')])
->filter()
->map(fn ($url) => rtrim((string) $url, '/'));
if ($known->contains($savedBase)) {
$this->webhook_endpoint = $savedBase;
$this->use_custom_webhook_endpoint = false;
} elseif (! (isCloud() && ! isDev()) && filled($savedBase)) {
$this->use_custom_webhook_endpoint = true;
$this->custom_webhook_endpoint = $savedBase;
}
}
$this->refreshRedirectUri();
$this->oauthState = $this->createOAuthState();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function refreshRedirectUri(): void
{
$base = $this->resolvePublicBaseUrl();
$this->redirectUri = $base === ''
? ''
: $base.'/webhooks/source/gitlab/redirect';
}
public function resolvePublicBaseUrl(): string
{
if ($this->use_custom_webhook_endpoint && filled($this->custom_webhook_endpoint)) {
return rtrim($this->custom_webhook_endpoint, '/');
}
return rtrim($this->webhook_endpoint ?: (config('app.url') ?? ''), '/');
}
public static function oauthStateCacheKey(string $state): string
{
return '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;
}
if (! empty($this->webhookToken)) {
$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->refreshRedirectUri();
$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;
if (Gate::allows('update', $this->gitlab_app)) {
$this->clientSecretInput = $this->gitlab_app->client_secret;
$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->validateOnly('isSystemWide');
$this->gitlab_app->is_system_wide = $this->isSystemWide;
$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 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
{
$this->refreshRedirectUri();
$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}";
}
}
+98
View File
@@ -0,0 +1,98 @@
<?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 Illuminate\Validation\ValidationException;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
public string $name;
public string $html_url = 'https://gitlab.com';
public string $api_url = 'https://gitlab.com/api/v4';
public string $custom_user = 'git';
public int $custom_port = 22;
public bool $is_system_wide = false;
public ?string $group_name = null;
private bool $shouldDeriveApiUrlAfterHtmlUrlUpdate = false;
public function mount()
{
$this->name = substr(generate_random_name(), 0, 30);
}
public function updatingHtmlUrl(): void
{
$this->shouldDeriveApiUrlAfterHtmlUrlUpdate = blank($this->api_url)
|| $this->api_url === $this->gitlabApiUrlFromHtmlUrl($this->html_url);
}
public function updatedHtmlUrl(): void
{
if ($this->shouldDeriveApiUrlAfterHtmlUrlUpdate) {
$this->api_url = $this->gitlabApiUrlFromHtmlUrl($this->html_url);
}
}
public function createGitLabApp()
{
try {
$this->authorize('createAnyResource');
$this->html_url = rtrim($this->html_url, '/');
$this->api_url = filled($this->api_url)
? rtrim($this->api_url, '/')
: $this->gitlabApiUrlFromHtmlUrl($this->html_url);
$this->validate([
'name' => 'required|string',
'html_url' => ['required', 'string', 'url', new SafeExternalUrl],
'api_url' => ['required', 'string', 'url', new SafeExternalUrl],
'custom_user' => 'required|string',
'custom_port' => 'required|int',
'is_system_wide' => 'required|bool',
'group_name' => 'nullable|string',
]);
$gitlab_app = GitlabApp::create([
'name' => $this->name,
'api_url' => $this->api_url,
'html_url' => $this->html_url,
'custom_user' => $this->custom_user,
'custom_port' => $this->custom_port,
'is_system_wide' => $this->is_system_wide,
'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 (ValidationException $e) {
throw $e;
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
private function gitlabApiUrlFromHtmlUrl(string $htmlUrl): string
{
return rtrim($htmlUrl, '/').'/api/v4';
}
}
+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);
+13
View File
@@ -98,4 +98,17 @@ class GithubApp extends BaseModel
},
);
}
/**
* A private GitHub App is connected once it has been registered and installed.
* Public sources do not require installation credentials.
*/
public function isConnected(): bool
{
if ($this->is_public) {
return true;
}
return filled($this->app_id) && filled($this->installation_id);
}
}
+115 -1
View File
@@ -2,6 +2,10 @@
namespace App\Models;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Support\Facades\Crypt;
class GitlabApp extends BaseModel
{
protected $fillable = [
@@ -16,20 +20,109 @@ 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',
];
}
/**
* Encrypt webhook tokens at rest. Supports legacy plaintext values until they are re-saved.
* Not a standard encrypted cast: webhooks look up by token value (see findByWebhookToken).
*/
protected function webhookToken(): Attribute
{
return Attribute::make(
get: function (?string $value): ?string {
if ($value === null || $value === '') {
return $value;
}
try {
return Crypt::decryptString($value);
} catch (DecryptException) {
// Legacy rows stored the token in plaintext.
return $value;
}
},
set: function (?string $value): ?string {
if ($value === null || $value === '') {
return $value;
}
return Crypt::encryptString($value);
},
);
}
public static function findByWebhookToken(string $token): ?self
{
if ($token === '') {
return null;
}
// Encrypted values cannot be matched with a SQL equality; sources are few per instance.
return static::query()->get()->first(
fn (self $app): bool => filled($app->webhook_token) && hash_equals((string) $app->webhook_token, $token)
);
}
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 +134,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;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace App\Policies;
use App\Models\GitlabApp;
use App\Models\User;
class GitlabAppPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, GitlabApp $gitlabApp): bool
{
if ($gitlabApp->is_system_wide) {
return true;
}
return $user->teams->contains('id', $gitlabApp->team_id);
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin();
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, GitlabApp $gitlabApp): bool
{
if ($gitlabApp->is_system_wide) {
return $user->canAccessSystemResources();
}
return $user->isAdminOfTeam($gitlabApp->team_id);
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, GitlabApp $gitlabApp): bool
{
if ($gitlabApp->is_system_wide) {
return $user->canAccessSystemResources();
}
return $user->isAdminOfTeam($gitlabApp->team_id);
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, GitlabApp $gitlabApp): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
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')
@@ -2,11 +2,18 @@
@if (data_get($github_app, 'app_id'))
<form wire:submit='submit'>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<h1>GitHub App</h1>
<div class="flex items-center gap-2">
<h1>GitHub App</h1>
@if ($isConnected)
<x-status-badge status="Connected" type="success" />
@endif
</div>
<div class="flex gap-2">
@if (data_get($github_app, 'installation_id'))
<x-forms.button canGate="update" :canResource="$github_app" type="submit"
:disabled="$activeTab !== 'general'">Save</x-forms.button>
<x-forms.button canGate="view" :canResource="$github_app"
wire:click.prevent="testConnection">Test Connection</x-forms.button>
@endif
@can('delete', $github_app)
@if ($applications->count() > 0)
@@ -0,0 +1,271 @@
<div>
@if ($isConnected)
<form wire:submit='submit'>
<div class="flex flex-col sm:flex-row sm:items-center gap-2">
<div class="flex items-center gap-2">
<h1>GitLab App</h1>
<x-status-badge status="Connected" type="success" />
</div>
<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 flex-col gap-2">
<x-forms.input canGate="update" :canResource="$gitlab_app" id="name" label="Name" />
@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>
@if ($isSystemWide)
<x-callout type="warning" title="Not Recommended">
System-wide GitLab Apps are shared across all teams on this Coolify instance. This means any team
can use this GitLab App to deploy applications from your repositories. For better security and
isolation, it's recommended to create team-specific GitLab Apps instead.
</x-callout>
@endif
@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"
helper="Stored encrypted. Leave empty and save other fields without changing the existing secret." />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="groupName" label="Group Name"
helper="Comma-separated group names to filter visible repositories." />
<div x-data="{
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
<div x-data="{ id: $id('accordion') }" class="cursor-pointer">
<button @click="setActiveAccordion(id)"
class="flex items-center justify-between w-full px-1 py-2 text-left select-none dark:hover:text-white hover:bg-white/5"
type="button">
<h4>Advanced / Self-hosted</h4>
<svg class="w-4 h-4 duration-200 ease-out" :class="{ 'rotate-180': activeAccordion == id }"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div x-show="activeAccordion==id" x-collapse x-cloak class="px-2">
<div class="flex flex-col gap-2 pt-0 opacity-70">
<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>
<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>
<div class="flex gap-2">
<x-forms.select canGate="update" :canResource="$gitlab_app" id="privateKeyId" label="SSH Private Key (optional)">
<option value="">None</option>
@foreach ($privateKeys as $key)
<option value="{{ $key->id }}">{{ $key->name }}</option>
@endforeach
</x-forms.select>
</div>
</div>
</div>
</div>
</div>
<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="{{ rtrim($this->resolvePublicBaseUrl(), '/') }}/webhooks/source/gitlab/events" />
<x-forms.input canGate="update" :canResource="$gitlab_app" id="webhookToken" label="Webhook Secret Token" type="password"
helper="Set this same token in your GitLab webhook's Secret token field. Stored encrypted." />
</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-error">
<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>You must complete this step before you can use this source!</span>
</div>
<div class="flex flex-col gap-4" x-data="{
webhookEndpoint: $wire.entangle('webhook_endpoint').live,
useCustomWebhookEndpoint: $wire.entangle('use_custom_webhook_endpoint').live,
customWebhookEndpoint: $wire.entangle('custom_webhook_endpoint').live,
redirectPath: '/webhooks/source/gitlab/redirect',
get redirectUri() {
const base = (this.useCustomWebhookEndpoint ? this.customWebhookEndpoint : this.webhookEndpoint) || '';
return base ? base.replace(/\/+$/, '') + this.redirectPath : '';
}
}">
<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 x-text="redirectUri || @js($redirectUri)">{{ $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>
<form wire:submit='submit' class="flex flex-col gap-2">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 pt-2">
<h3>Step 2: Enter the credentials</h3>
<x-forms.button type="submit">Save</x-forms.button>
</div>
<x-forms.input id="name" label="Name" />
<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="blank($clientSecretInput) && blank(data_get($gitlab_app, 'client_secret'))"
helper="The Secret from your GitLab OAuth Application. Saved encrypted and shown again after reload." />
<x-forms.input id="groupName" label="Group Name"
helper="Optional. Comma-separated group names to filter repositories." />
@if (!isCloud() || isDev())
<div class="flex flex-col gap-3 pt-2">
<div class="text-sm dark:text-neutral-400">
GitLab will redirect back to this Coolify URL. It must match the Callback URL on your GitLab OAuth Application exactly.
</div>
<x-forms.checkbox x-model="useCustomWebhookEndpoint" id="use_custom_webhook_endpoint"
label="Use custom webhook endpoint"
helper="Enable this when the public URL GitLab should call differs from Coolify's configured URL, for example behind Cloudflare Tunnel or when accessing via a LAN IP." />
<div x-show="!useCustomWebhookEndpoint">
<x-forms.select wire:model.live='webhook_endpoint' x-model="webhookEndpoint"
label="Selected endpoint"
helper="GitLab will use this endpoint unless custom mode is enabled.">
@if ($fqdn)
<option value="{{ $fqdn }}">Use {{ $fqdn }}</option>
@endif
@if ($ipv4)
<option value="{{ $ipv4 }}">Use {{ $ipv4 }}</option>
@endif
@if ($ipv6)
<option value="{{ $ipv6 }}">Use {{ $ipv6 }}</option>
@endif
@if (config('app.url'))
<option value="{{ config('app.url') }}">Use {{ config('app.url') }}</option>
@endif
</x-forms.select>
</div>
<div x-cloak x-show="useCustomWebhookEndpoint">
<x-forms.input x-model="customWebhookEndpoint" id="custom_webhook_endpoint" type="url"
label="Custom endpoint" placeholder="https://coolify.example.com"
helper="GitLab will use this custom public URL. Do not include /webhooks." />
</div>
</div>
@endif
<div x-data="{
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
<div x-data="{ id: $id('accordion') }" class="cursor-pointer">
<button @click="setActiveAccordion(id)"
class="flex items-center justify-between w-full px-1 py-2 text-left select-none dark:hover:text-white hover:bg-white/5"
type="button">
<h4>Advanced / Self-hosted</h4>
<svg class="w-4 h-4 duration-200 ease-out" :class="{ 'rotate-180': activeAccordion == id }"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div x-show="activeAccordion==id" x-collapse x-cloak class="px-2">
<div class="flex flex-col gap-2 pt-0 opacity-70">
<div class="flex gap-2">
<x-forms.input id="htmlUrl" label="GitLab URL"
helper="Only change this for self-hosted GitLab (e.g. https://gitlab.example.com)." />
<x-forms.input id="apiUrl" label="API URL"
helper="Usually your GitLab URL with /api/v4 appended." />
</div>
<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())
<div class="w-48">
<x-forms.checkbox label="System Wide" id="isSystemWide"
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
</div>
@endif
</div>
</div>
</div>
</div>
</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. The redirect URI must match the Callback URL configured in GitLab.</div>
<a href="{{ $this->getOAuthUrl() }}" class="w-fit" wire:key="oauth-url-{{ md5((string) $redirectUri) }}">
<x-forms.button class="mt-2">
Connect to GitLab
</x-forms.button>
</a>
@endif
</div>
@endif
</div>
@@ -0,0 +1,70 @@
@can('createAnyResource')
<form wire:submit='createGitLabApp' class="flex flex-col w-full gap-2">
<div class="pb-2">This is required if you would like to get full integration (deployments from
private repositories, webhooks, etc) with GitLab.</div>
<div class="flex gap-2">
<x-forms.input id="name" label="Name" required />
<x-forms.input id="group_name" label="Group Name"
helper="Optional. Comma-separated group names to filter repositories (e.g., myorg,myteam)."
placeholder="If empty, all accessible repositories are listed." />
</div>
@if (!isCloud())
<div x-data="{ showWarning: @entangle('is_system_wide') }">
<div class="w-48">
<x-forms.checkbox id="is_system_wide" label="System Wide"
helper="If checked, this GitLab App will be available for everyone in this Coolify instance." />
</div>
<div x-show="showWarning" x-transition x-cloak class="w-full max-w-2xl mx-auto pt-2">
<x-callout type="warning" title="Not Recommended">
<div class="whitespace-normal break-words">
System-wide GitLab Apps are shared across all teams on this Coolify instance. This means any team
can use this GitLab App to deploy applications from your repositories. For better security and
isolation, it's recommended to create team-specific GitLab Apps instead.
</div>
</x-callout>
</div>
</div>
@endif
<div x-data="{
activeAccordion: '',
setActiveAccordion(id) {
this.activeAccordion = (this.activeAccordion == id) ? '' : id
}
}" class="relative w-full py-2 mx-auto overflow-hidden text-sm font-normal rounded-md">
<div x-data="{ id: $id('accordion') }" class="cursor-pointer">
<button @click="setActiveAccordion(id)"
class="flex items-center justify-between w-full px-1 py-2 text-left select-none dark:hover:text-white hover:bg-white/5"
type="button">
<h4>Self-hosted GitLab</h4>
<svg class="w-4 h-4 duration-200 ease-out" :class="{ 'rotate-180': activeAccordion == id }"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
<div x-show="activeAccordion==id" x-collapse x-cloak class="px-2">
<div class="flex flex-col gap-2 pt-0 opacity-70">
<div class="flex gap-2">
<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="api_url" label="API URL" required
helper="Usually your GitLab URL with /api/v4 appended." />
</div>
<div class="flex gap-2">
<x-forms.input id="custom_user" label="Custom Git User" required />
<x-forms.input id="custom_port" type="number" label="Custom Git Port" required />
</div>
</div>
</div>
</div>
</div>
<x-forms.button class="mt-4" type="submit">
Continue
</x-forms.button>
</form>
@else
<x-callout type="danger" title="Insufficient Permissions">
You don't have permission to create new GitLab Apps. Please contact your team administrator for access.
</x-callout>
@endcan
+27 -8
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,15 +20,31 @@
<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))
<span class="box-description text-error! ">Configuration is not finished.</span>
<div class="box-title">
<x-git-icon class="inline-block w-4 h-4 mr-1" git="App\Models\GithubApp" />
{{ $source->name }}
</div>
@if ($source->isConnected())
<span class="box-description text-success">Connected</span>
@else
@if ($source->organization)
<span class="box-description">Organization: {{ $source->organization }}</span>
@endif
<span class="box-description text-warning">Setup required</span>
@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</span>
@else
<span class="box-description text-warning">Setup required</span>
@endif
</div>
</a>
+7 -1
View File
@@ -7,9 +7,9 @@ use App\Http\Controllers\Api\DeployController;
use App\Http\Controllers\Api\DestinationsController;
use App\Http\Controllers\Api\DigitalOceanController;
use App\Http\Controllers\Api\GithubController;
use App\Http\Controllers\Api\GitlabController;
use App\Http\Controllers\Api\HetznerController;
use App\Http\Controllers\Api\Internal\FluxResourceStatusController;
use App\Support\V5\V5Feature;
use App\Http\Controllers\Api\OtherController;
use App\Http\Controllers\Api\ProjectController;
use App\Http\Controllers\Api\ResourcesController;
@@ -25,6 +25,7 @@ use App\Http\Controllers\Api\TeamController;
use App\Http\Controllers\Api\VolumeBackupsController;
use App\Http\Controllers\Api\VultrController;
use App\Http\Middleware\ApiAllowed;
use App\Support\V5\V5Feature;
use Illuminate\Support\Facades\Route;
Route::get('/health', [OtherController::class, 'healthcheck']);
@@ -185,6 +186,11 @@ Route::group([
Route::get('/github-apps/{github_app_id}/repositories', [GithubController::class, 'load_repositories'])->middleware(['api.ability:read']);
Route::get('/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches', [GithubController::class, 'load_branches'])->middleware(['api.ability:read']);
Route::get('/gitlab-apps', [GitlabController::class, 'list_gitlab_apps'])->middleware(['api.ability:read']);
Route::post('/gitlab-apps', [GitlabController::class, 'create_gitlab_app'])->middleware(['api.ability:write']);
Route::patch('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'update_gitlab_app'])->middleware(['api.ability:write']);
Route::delete('/gitlab-apps/{gitlab_app_id}', [GitlabController::class, 'delete_gitlab_app'])->middleware(['api.ability:write']);
Route::get('/databases', [DatabasesController::class, 'databases'])->middleware(['api.ability:read']);
Route::post('/databases/postgresql', [DatabasesController::class, 'create_database_postgresql'])->middleware(['api.ability:write']);
Route::post('/databases/mysql', [DatabasesController::class, 'create_database_mysql'])->middleware(['api.ability:write']);
+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']);
+182
View File
@@ -0,0 +1,182 @@
<?php
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.driver', 'file');
config()->set('cache.default', 'array');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->token = $this->user->createToken('test-token', ['*']);
$this->bearerToken = $this->token->plainTextToken;
});
describe('GET /api/v1/gitlab-apps', function () {
test('returns 401 when not authenticated', function () {
$this->getJson('/api/v1/gitlab-apps')->assertStatus(401);
});
test('returns empty array when no gitlab apps exist', function () {
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->getJson('/api/v1/gitlab-apps')
->assertSuccessful()
->assertJson([]);
});
test('returns team gitlab apps without secrets for read tokens', function () {
GitlabApp::create([
'name' => 'Team GitLab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'client_id' => 'client-id',
'client_secret' => 'secret-should-be-hidden',
'webhook_token' => 'webhook-should-be-hidden',
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson('/api/v1/gitlab-apps');
$response->assertSuccessful()
->assertJsonCount(1)
->assertJsonFragment(['name' => 'Team GitLab']);
expect($response->json('0'))->not->toHaveKey('client_secret')
->and($response->json('0'))->not->toHaveKey('webhook_token');
});
});
describe('POST /api/v1/gitlab-apps', function () {
test('creates a gitlab app with derived api url and generated webhook token', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Self-hosted GitLab',
'html_url' => 'https://gitlab.com/',
'group_name' => 'mygroup',
]);
$response->assertCreated()
->assertJsonFragment([
'name' => 'Self-hosted GitLab',
'html_url' => 'https://gitlab.com',
'api_url' => 'https://gitlab.com/api/v4',
'group_name' => 'mygroup',
'custom_user' => 'git',
'custom_port' => 22,
]);
$app = GitlabApp::where('name', 'Self-hosted GitLab')->first();
expect($app)->not->toBeNull()
->and($app->team_id)->toBe($this->team->id)
->and($app->webhook_token)->not->toBeEmpty()
->and(strlen((string) $app->webhook_token))->toBe(32);
});
test('creates a fully configured gitlab oauth source', function () {
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Configured GitLab',
'html_url' => 'https://gitlab.com',
'client_id' => 'oauth-app-id',
'client_secret' => 'oauth-app-secret',
'webhook_token' => 'custom-webhook-token',
'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
]);
$response->assertCreated()
->assertJsonFragment([
'name' => 'Configured GitLab',
'client_id' => 'oauth-app-id',
'redirect_uri' => 'https://example.com/webhooks/source/gitlab/redirect',
]);
$app = GitlabApp::where('name', 'Configured GitLab')->first();
$app->makeVisible(['client_secret', 'webhook_token']);
expect($app->client_secret)->toBe('oauth-app-secret')
->and($app->webhook_token)->toBe('custom-webhook-token');
});
test('rejects members without create permission', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
session(['currentTeam' => $this->team]);
$memberToken = $member->createToken('member-token', ['write'])->plainTextToken;
$this->withHeaders([
'Authorization' => 'Bearer '.$memberToken,
])->postJson('/api/v1/gitlab-apps', [
'name' => 'Forbidden GitLab',
'html_url' => 'https://gitlab.com',
])->assertForbidden();
});
});
describe('PATCH /api/v1/gitlab-apps/{id}', function () {
test('updates gitlab app credentials', function () {
$app = GitlabApp::create([
'name' => 'Existing',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->patchJson("/api/v1/gitlab-apps/{$app->id}", [
'client_id' => 'new-client-id',
'group_name' => 'ops',
])->assertSuccessful()
->assertJsonPath('message', 'GitLab app updated successfully')
->assertJsonPath('data.client_id', 'new-client-id')
->assertJsonPath('data.group_name', 'ops');
});
});
describe('DELETE /api/v1/gitlab-apps/{id}', function () {
test('deletes unused gitlab app', function () {
$app = GitlabApp::create([
'name' => 'Delete me',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
])->deleteJson("/api/v1/gitlab-apps/{$app->id}")
->assertSuccessful()
->assertJsonPath('message', 'GitLab app deleted successfully');
expect(GitlabApp::find($app->id))->toBeNull();
});
});
@@ -653,4 +653,161 @@ describe('GitHub Source Change Component', function () {
Http::assertSent(fn ($request) => $request->url() === 'https://api.github.ghe.com/app');
});
test('isConnected is true only when app and installation are present', function () {
$incomplete = GithubApp::create([
'name' => 'Incomplete App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$connected = GithubApp::create([
'name' => 'Connected App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$public = new GithubApp([
'is_public' => true,
]);
expect($incomplete->isConnected())->toBeFalse()
->and($connected->isConnected())->toBeTrue()
->and($public->isConnected())->toBeTrue();
});
test('shows connected badge and test connection for installed github apps', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Connected GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('isConnected', true)
->assertSee('Connected')
->assertSee('Test Connection');
});
test('testConnection succeeds when github app credentials are valid', function () {
$privateKey = PrivateKey::create([
'name' => 'Test Key',
'private_key' => validPrivateKey(),
'team_id' => $this->team->id,
]);
$githubApp = GithubApp::create([
'name' => 'Connected GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'private_key_id' => $privateKey->id,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Http::preventStrayRequests();
Http::fake([
'https://api.github.com/zen' => Http::response('Keep it logically awesome.', 200, [
'date' => now()->toRfc7231String(),
]),
'https://api.github.com/app' => Http::response([
'name' => 'Coolify GitHub App',
'slug' => 'coolify-github-app',
]),
'https://api.github.com/app/installations/67890/access_tokens' => Http::response([
'token' => 'ghs_test_installation_token',
]),
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->call('testConnection')
->assertDispatched('success', 'Connection successful! Authenticated as GitHub App: Coolify GitHub App');
});
test('testConnection fails when github app is not fully installed', function () {
$githubApp = GithubApp::create([
'name' => 'Incomplete GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'team_id' => $this->team->id,
'is_system_wide' => false,
]);
Livewire::withQueryParams(['github_app_uuid' => $githubApp->uuid])
->test(Change::class)
->assertSuccessful()
->assertSet('isConnected', false)
->call('testConnection')
->assertDispatched('error', 'GitHub App is not fully set up. Please complete installation first.');
});
test('sources list shows Connected for finished github apps', function () {
GithubApp::create([
'name' => 'Finished GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'app_id' => 12345,
'installation_id' => 67890,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
GithubApp::create([
'name' => 'Incomplete GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$this->get(route('source.all'))
->assertSuccessful()
->assertSee('Finished GitHub App')
->assertSee('Connected')
->assertSee('Incomplete GitHub App')
->assertSee('Setup required');
});
});
@@ -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,147 @@
<?php
use App\Livewire\Project\New\GitlabPrivateRepository;
use App\Livewire\Source\Gitlab\Change;
use App\Models\Application;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->owner = User::factory()->create();
$this->member = User::factory()->create();
$this->team->members()->attach($this->owner->id, ['role' => 'owner']);
$this->team->members()->attach($this->member->id, ['role' => 'member']);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
$this->gitlabApp = GitlabApp::create([
'name' => 'Self-hosted GitLab',
'api_url' => 'https://gitlab.example.com/api/v4',
'html_url' => 'https://gitlab.example.com',
'custom_user' => 'git',
'custom_port' => 22,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'webhook_token' => 'secret-webhook-token',
'access_token' => 'access-token',
'refresh_token' => 'refresh-token',
'expires_at' => time() + 3600,
'redirect_uri' => 'https://coolify.example.com/webhooks/source/gitlab/redirect',
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
});
describe('GitLab App authorization', function () {
test('unrelated users cannot inspect system-wide source secrets in the component payload', function () {
$otherTeam = Team::factory()->create();
$systemWideSource = GitlabApp::create([
'name' => 'Shared GitLab',
'api_url' => 'https://gitlab.example.com/api/v4',
'html_url' => 'https://gitlab.example.com',
'custom_user' => 'git',
'custom_port' => 22,
'client_id' => 'shared-client-id',
'client_secret' => 'shared-client-secret',
'webhook_token' => 'shared-webhook-token',
'access_token' => 'shared-access-token',
'refresh_token' => 'shared-refresh-token',
'expires_at' => time() + 3600,
'team_id' => $otherTeam->id,
'is_system_wide' => true,
'is_public' => false,
]);
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
$component = Livewire::withQueryParams(['gitlab_app_uuid' => $systemWideSource->uuid])
->test(Change::class)
->assertSet('clientSecretInput', null)
->assertSet('webhookToken', null);
expect($component->html())
->not->toContain('shared-client-secret')
->not->toContain('shared-webhook-token');
});
test('team member cannot update a gitlab app via instantSave', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->set('isSystemWide', true)
->call('instantSave')
->assertDispatched('error');
expect($this->gitlabApp->refresh()->is_system_wide)->toBeFalse();
});
test('team owner can update a gitlab app via instantSave', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->set('isSystemWide', true)
->call('instantSave')
->assertDispatched('success');
expect($this->gitlabApp->refresh()->is_system_wide)->toBeTrue();
});
test('instantSave rejects unsafe GitLab URLs', function (string $url) {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->set('htmlUrl', $url)
->set('apiUrl', $url.'/api/v4')
->set('isSystemWide', true)
->call('instantSave')
->assertDispatched('success');
$this->gitlabApp->refresh();
expect($this->gitlabApp->html_url)->toBe('https://gitlab.example.com')
->and($this->gitlabApp->api_url)->toBe('https://gitlab.example.com/api/v4')
->and($this->gitlabApp->is_system_wide)->toBeTrue();
})->with([
'private address' => 'http://10.0.0.1',
'loopback address' => 'http://127.0.0.1',
'metadata service address' => 'http://169.254.169.254',
]);
test('team member cannot create an application from a private gitlab repository', function () {
$this->actingAs($this->member);
session(['currentTeam' => $this->team]);
$applicationsBefore = Application::count();
// Avoid setting selected_project_id — its updated* hook loads branches and is unrelated to this auth check.
Livewire::test(GitlabPrivateRepository::class, ['type' => 'private-gitlab-app'])
->set('selected_repository_path', 'group/repo')
->set('selected_branch_name', 'main')
->set('selected_gitlab_app_id', $this->gitlabApp->id)
->set('gitlab_app_id', $this->gitlabApp->id)
->call('submit')
->assertDispatched('error');
expect(Application::count())->toBe($applicationsBefore);
});
});
@@ -0,0 +1,67 @@
<?php
use App\Models\GitlabApp;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::create([
'name' => 'Webhook Token Team',
'personal_team' => false,
]);
});
it('encrypts webhook tokens at rest', function () {
$app = GitlabApp::create([
'name' => 'Encrypted webhook',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'webhook_token' => 'plain-webhook-secret',
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$raw = DB::table('gitlab_apps')->where('id', $app->id)->value('webhook_token');
expect($raw)->not->toBe('plain-webhook-secret')
->and(Crypt::decryptString($raw))->toBe('plain-webhook-secret')
->and($app->fresh()->webhook_token)->toBe('plain-webhook-secret');
});
it('finds an app by webhook token for both encrypted and legacy plaintext values', function () {
$encrypted = GitlabApp::create([
'name' => 'Encrypted',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'webhook_token' => 'encrypted-secret',
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
$legacy = GitlabApp::create([
'name' => 'Legacy',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
DB::table('gitlab_apps')->where('id', $legacy->id)->update([
'webhook_token' => 'legacy-plain-secret',
]);
expect(GitlabApp::findByWebhookToken('encrypted-secret')?->id)->toBe($encrypted->id)
->and(GitlabApp::findByWebhookToken('legacy-plain-secret')?->id)->toBe($legacy->id)
->and(GitlabApp::findByWebhookToken('missing'))->toBeNull();
});
@@ -0,0 +1,129 @@
<?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'));
});
test('rejects a callback from a team member who cannot administer the source', function () {
Http::fake();
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
$state = 'member-state';
Cache::put(GitlabSource::oauthStateCacheKey($state), [
'gitlab_app_id' => $this->gitlabApp->id,
'team_id' => $this->team->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();
});
});
@@ -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,89 @@
<?php
use App\Livewire\Source\Gitlab\Change;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => null,
'public_ipv4' => null,
'public_ipv6' => null,
]);
$this->gitlabApp = GitlabApp::create([
'name' => 'Self-hosted GitLab',
'api_url' => 'https://gitlab.com/api/v4',
'html_url' => 'https://gitlab.com',
'custom_user' => 'git',
'custom_port' => 22,
'team_id' => $this->team->id,
'is_system_wide' => false,
'is_public' => false,
]);
});
describe('GitLab source setup view', function () {
test('shows red incomplete-setup alert and keeps advanced fields collapsed', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSee('You must complete this step before you can use this source!')
->assertSeeHtml('alert-error')
->assertSee('Advanced / Self-hosted')
->assertSee('Application ID')
->assertSee('Application Secret')
->assertSee('Save')
->assertDontSee('alert-warning');
});
test('derives api url when gitlab url changes', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->set('htmlUrl', 'https://gitlab.example.com')
->assertSet('apiUrl', 'https://gitlab.example.com/api/v4');
});
test('saves and reloads the application secret after refresh', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->set('clientId', 'gitlab-app-id')
->set('clientSecretInput', 'super-secret-value')
->call('submit')
->assertDispatched('success');
$this->gitlabApp->refresh()->makeVisible(['client_secret']);
expect($this->gitlabApp->client_secret)->toBe('super-secret-value');
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSet('clientId', 'gitlab-app-id')
->assertSet('clientSecretInput', 'super-secret-value');
});
test('supports github-style custom public endpoint for oauth redirect uri', function () {
Livewire::withQueryParams(['gitlab_app_uuid' => $this->gitlabApp->uuid])
->test(Change::class)
->assertSee('Use custom webhook endpoint')
->assertSee('Selected endpoint')
->set('use_custom_webhook_endpoint', true)
->set('custom_webhook_endpoint', 'http://100.75.155.70:8000')
->assertSet('redirectUri', 'http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
expect($this->gitlabApp->refresh()->redirect_uri)
->toBe('http://100.75.155.70:8000/webhooks/source/gitlab/redirect');
});
});
@@ -0,0 +1,50 @@
<?php
use App\Livewire\Source\Gitlab\Create;
use App\Models\GitlabApp;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
});
describe('GitLab source create modal', function () {
test('matches github create modal structure', function () {
Livewire::test(Create::class)
->assertSee('This is required if you would like to get full integration')
->assertSee('Self-hosted GitLab')
->assertSee('Continue')
->assertDontSee('>Save</', false)
->assertDontSeeHtml('<h2>New GitLab App</h2>');
});
test('creates a gitlab app with defaults for gitlab.com', function () {
Livewire::test(Create::class)
->set('name', 'my-gitlab')
->call('createGitLabApp')
->assertRedirect();
$app = GitlabApp::where('name', 'my-gitlab')->first();
expect($app)->not->toBeNull()
->and($app->html_url)->toBe('https://gitlab.com')
->and($app->api_url)->toBe('https://gitlab.com/api/v4')
->and($app->custom_user)->toBe('git')
->and($app->custom_port)->toBe(22);
});
test('derives api url when html url changes', function () {
Livewire::test(Create::class)
->set('html_url', 'https://gitlab.example.com')
->assertSet('api_url', 'https://gitlab.example.com/api/v4');
});
});
@@ -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');
});
+172
View File
@@ -0,0 +1,172 @@
<?php
use App\Models\GitlabApp;
use App\Models\User;
use App\Policies\GitlabAppPolicy;
it('allows any user to view any gitlab apps', function () {
$user = Mockery::mock(User::class)->makePartial();
$policy = new GitlabAppPolicy;
expect($policy->viewAny($user))->toBeTrue();
});
it('allows any user to view system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
$policy = new GitlabAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('allows team member to view non-system-wide gitlab app', function () {
$teams = collect([
(object) ['id' => 1, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->view($user, $model))->toBeTrue();
});
it('denies non-team member to view non-system-wide gitlab app', function () {
$teams = collect([
(object) ['id' => 2, 'pivot' => (object) ['role' => 'member']],
]);
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('getAttribute')->with('teams')->andReturn($teams);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->view($user, $model))->toBeFalse();
});
it('allows admin to create gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(true);
$policy = new GitlabAppPolicy;
expect($policy->create($user))->toBeTrue();
});
it('denies non-admin to create gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdmin')->andReturn(false);
$policy = new GitlabAppPolicy;
expect($policy->create($user))->toBeFalse();
});
it('allows user with system access to update system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
$policy = new GitlabAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies user without system access to update system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
$policy = new GitlabAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows team admin to update non-system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->update($user, $model))->toBeTrue();
});
it('denies team member to update non-system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->update($user, $model))->toBeFalse();
});
it('allows user with system access to delete system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(true);
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
$policy = new GitlabAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies user without system access to delete system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('canAccessSystemResources')->andReturn(false);
$model = mockGitlabApp(teamId: 1, isSystemWide: true);
$policy = new GitlabAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('allows team admin to delete non-system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(true);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->delete($user, $model))->toBeTrue();
});
it('denies team member to delete non-system-wide gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$user->shouldReceive('isAdminOfTeam')->with(1)->andReturn(false);
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->delete($user, $model))->toBeFalse();
});
it('denies restore of gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->restore($user, $model))->toBeFalse();
});
it('denies force delete of gitlab app', function () {
$user = Mockery::mock(User::class)->makePartial();
$model = mockGitlabApp(teamId: 1, isSystemWide: false);
$policy = new GitlabAppPolicy;
expect($policy->forceDelete($user, $model))->toBeFalse();
});
function mockGitlabApp(int $teamId, bool $isSystemWide): GitlabApp
{
$gitlabApp = Mockery::mock(GitlabApp::class)->makePartial();
$gitlabApp->team_id = $teamId;
$gitlabApp->is_system_wide = $isSystemWide;
return $gitlabApp;
}