mirror of
https://github.com/tiennm99/coolify.git
synced 2026-08-20 18:23:40 +00:00
feat: GitLab App source (#10538)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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!');
|
||||
}
|
||||
}
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -102,5 +102,16 @@ class AppServiceProvider extends ServiceProvider
|
||||
])->baseUrl($api_url);
|
||||
}
|
||||
});
|
||||
|
||||
Http::macro('GitLab', function (string $api_url, ?string $access_token = null) {
|
||||
$client = Http::withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
])->baseUrl($api_url);
|
||||
if ($access_token) {
|
||||
$client = $client->withToken($access_token);
|
||||
}
|
||||
|
||||
return $client;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Models\EmailNotificationSettings;
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
@@ -51,6 +52,7 @@ use App\Policies\DatabasePolicy;
|
||||
use App\Policies\EnvironmentPolicy;
|
||||
use App\Policies\EnvironmentVariablePolicy;
|
||||
use App\Policies\GithubAppPolicy;
|
||||
use App\Policies\GitlabAppPolicy;
|
||||
use App\Policies\InstanceSettingsPolicy;
|
||||
use App\Policies\NotificationPolicy;
|
||||
use App\Policies\PrivateKeyPolicy;
|
||||
@@ -127,6 +129,9 @@ class AuthServiceProvider extends ServiceProvider
|
||||
|
||||
// Git source policies
|
||||
GithubApp::class => GithubAppPolicy::class,
|
||||
GitlabApp::class => GitlabAppPolicy::class,
|
||||
|
||||
// Cloud provider policies
|
||||
CloudProviderToken::class => CloudProviderTokenPolicy::class,
|
||||
CloudInitScript::class => CloudInitScriptPolicy::class,
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
@@ -216,7 +215,7 @@ function generateGithubJwt(GithubApp $source)
|
||||
return generateGithubToken($source, 'jwt');
|
||||
}
|
||||
|
||||
function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
|
||||
{
|
||||
if (is_null($source)) {
|
||||
throw new Exception('Source is required for API calls');
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
function refreshGitlabToken(GitlabApp $source): void
|
||||
{
|
||||
if (! $source->refresh_token) {
|
||||
throw new RuntimeException('GitLab source has no refresh token. Please reconnect the GitLab app.');
|
||||
}
|
||||
|
||||
$safetyMargin = 60;
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("gitlab_token_refresh_{$source->id}", 20);
|
||||
if (! $lock->block(20)) {
|
||||
$source->refresh();
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('GitLab token refresh timed out. Please try again.');
|
||||
}
|
||||
|
||||
try {
|
||||
$source->refresh();
|
||||
if ($source->expires_at && $source->expires_at > time() + $safetyMargin) {
|
||||
return;
|
||||
}
|
||||
|
||||
$baseUrl = rtrim($source->html_url, '/');
|
||||
|
||||
$response = Http::asForm()->post("{$baseUrl}/oauth/token", [
|
||||
'client_id' => $source->client_id,
|
||||
'client_secret' => $source->client_secret,
|
||||
'refresh_token' => $source->refresh_token,
|
||||
'grant_type' => 'refresh_token',
|
||||
'redirect_uri' => $source->redirect_uri,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
$error = data_get($response->json(), 'error_description', $response->body());
|
||||
throw new RuntimeException("Failed to refresh GitLab token: {$error}");
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
$source->update([
|
||||
'access_token' => $data['access_token'],
|
||||
'refresh_token' => $data['refresh_token'],
|
||||
'expires_at' => time() + ($data['expires_in'] ?? 7200),
|
||||
]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
function gitlabApi(GitlabApp $source, string $endpoint, string $method = 'get', ?array $data = null): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
|
||||
$client = Http::GitLab($apiUrl, $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false);
|
||||
|
||||
if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) {
|
||||
$response = $client->$method($endpoint, $data);
|
||||
} else {
|
||||
$response = $client->$method($endpoint);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorMessage = data_get($response->json(), 'message', $response->body());
|
||||
throw new RuntimeException("GitLab API call failed: {$errorMessage}");
|
||||
}
|
||||
|
||||
return [
|
||||
'data' => collect($response->json()),
|
||||
'total' => (int) $response->header('x-total', 0),
|
||||
];
|
||||
}
|
||||
|
||||
function generateGitlabCloneToken(GitlabApp $source): string
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
return $source->access_token;
|
||||
}
|
||||
|
||||
function loadGitlabRepositories(GitlabApp $source, int $page = 1): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
$response = Http::GitLab($apiUrl, $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get('/projects', [
|
||||
'membership' => 'true',
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
'order_by' => 'name',
|
||||
'sort' => 'asc',
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return ['total_count' => 0, 'has_more' => false, 'repositories' => []];
|
||||
}
|
||||
|
||||
$projects = collect($response->json());
|
||||
$rawCount = count($response->json());
|
||||
$hasMore = $rawCount === 100;
|
||||
|
||||
$groupName = $source->group_name;
|
||||
if (! empty($groupName)) {
|
||||
$groups = collect(explode(',', $groupName))->map(fn ($g) => strtolower(trim($g)))->filter();
|
||||
$projects = $projects->filter(function ($project) use ($groups) {
|
||||
$namespacePath = strtolower(data_get($project, 'namespace.full_path', ''));
|
||||
|
||||
return $groups->contains(fn ($group) => $namespacePath === $group || str_starts_with($namespacePath, $group.'/'));
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'total_count' => $projects->count(),
|
||||
'has_more' => $hasMore,
|
||||
'repositories' => $projects->map(fn ($project) => [
|
||||
'id' => data_get($project, 'id'),
|
||||
'name' => data_get($project, 'name'),
|
||||
'path_with_namespace' => data_get($project, 'path_with_namespace'),
|
||||
'default_branch' => data_get($project, 'default_branch', 'main'),
|
||||
'web_url' => data_get($project, 'web_url'),
|
||||
'namespace' => [
|
||||
'full_path' => data_get($project, 'namespace.full_path'),
|
||||
'kind' => data_get($project, 'namespace.kind'),
|
||||
],
|
||||
])->values()->all(),
|
||||
];
|
||||
}
|
||||
|
||||
function loadGitlabBranches(GitlabApp $source, int $projectId, int $page = 1): array
|
||||
{
|
||||
refreshGitlabToken($source);
|
||||
|
||||
$apiUrl = $source->apiUrlBase();
|
||||
$response = Http::GitLab($apiUrl, $source->access_token)
|
||||
->timeout(20)
|
||||
->retry(3, 200, throw: false)
|
||||
->get("/projects/{$projectId}/repository/branches", [
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
@@ -301,6 +301,7 @@ function remove_iip($text)
|
||||
|
||||
// Git access tokens
|
||||
$text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text);
|
||||
$text = preg_replace('/oauth2:.*?(?=@)/', 'oauth2:'.REDACTED, $text);
|
||||
|
||||
// ANSI color codes
|
||||
$text = preg_replace('/\x1b\[[0-9;]*m/', '', $text);
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('gitlab_apps', function (Blueprint $table) {
|
||||
$table->longText('client_id')->nullable()->after('app_secret');
|
||||
$table->longText('client_secret')->nullable()->after('client_id');
|
||||
$table->longText('access_token')->nullable()->after('client_secret');
|
||||
$table->longText('refresh_token')->nullable()->after('access_token');
|
||||
$table->integer('expires_at')->nullable()->after('refresh_token');
|
||||
$table->string('redirect_uri')->nullable()->after('expires_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('gitlab_apps', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'client_id',
|
||||
'client_secret',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'expires_at',
|
||||
'redirect_uri',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -370,6 +370,12 @@ CREATE TABLE IF NOT EXISTS "gitlab_apps" (
|
||||
"is_public" INTEGER DEFAULT false NOT NULL,
|
||||
"app_id" INTEGER,
|
||||
"app_secret" TEXT,
|
||||
"client_id" TEXT,
|
||||
"client_secret" TEXT,
|
||||
"access_token" TEXT,
|
||||
"refresh_token" TEXT,
|
||||
"expires_at" INTEGER,
|
||||
"redirect_uri" TEXT,
|
||||
"oauth_id" INTEGER,
|
||||
"group_name" TEXT,
|
||||
"public_key" TEXT,
|
||||
@@ -1898,6 +1904,7 @@ INSERT INTO "migrations" ("id", "migration", "batch") VALUES (311, '2025_12_10_1
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (312, '2025_12_15_143052_trim_s3_storage_credentials', 312);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (313, '2025_12_17_000001_add_is_wire_navigate_enabled_to_instance_settings_table', 313);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (314, '2025_12_17_000002_add_restart_tracking_to_standalone_databases', 314);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (315, '2026_06_03_000000_add_oauth_fields_to_gitlab_apps_table', 315);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (316, '2026_06_16_130649_v5_create_clusters_table', 316);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (317, '2026_06_16_130650_v5_create_servers_table', 317);
|
||||
INSERT INTO "migrations" ("id", "migration", "batch") VALUES (318, '2026_06_19_140000_v5_create_applications_table', 318);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
Open Repository
|
||||
<x-external-link />
|
||||
</a>
|
||||
@if (data_get($application, 'source.is_public') === false)
|
||||
@if (data_get($application, 'source.is_public') === false && $application->source instanceof \App\Models\GithubApp)
|
||||
<a target="_blank" class="hover:no-underline flex items-center gap-1"
|
||||
href="{{ getInstallationPath($application->source) }}">
|
||||
Open Git App
|
||||
@@ -67,7 +67,7 @@
|
||||
@forelse ($sources as $source)
|
||||
<div wire:key="{{ $source->name }}">
|
||||
<x-modal-confirmation title="Change Git Source" :actions="['Change git source to ' . $source->name]" :buttonFullWidth="true"
|
||||
:isHighlightedButton="$application->source_id === $source->id" :disabled="$application->source_id === $source->id"
|
||||
:isHighlightedButton="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()" :disabled="$application->source_id === $source->id && $application->source_type === $source->getMorphClass()"
|
||||
submitAction="changeSource({{ $source->id }}, {{ $source->getMorphClass() }})"
|
||||
:confirmWithText="true" confirmationText="Change Git Source"
|
||||
confirmationLabel="Please confirm changing the git source by entering the text below"
|
||||
@@ -76,7 +76,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="box-title">
|
||||
{{ $source->name }}
|
||||
@if ($application->source_id === $source->id)
|
||||
@if ($application->source_id === $source->id && $application->source_type === $source->getMorphClass())
|
||||
<span class="text-xs">(current)</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<div>
|
||||
<div class="flex items-end gap-2">
|
||||
<h1>Create a new Application</h1>
|
||||
<x-modal-input buttonTitle="+ Add GitLab App" title="New GitLab App" closeOutside="false">
|
||||
<livewire:source.gitlab.create />
|
||||
</x-modal-input>
|
||||
@if ($repositories->count() > 0 && $gitlab_app_id)
|
||||
<x-forms.button wire:click.prevent="loadRepositories({{ $gitlab_app_id }})">
|
||||
Refresh Repository List
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
<div class="pb-4">Deploy any public or private Git repositories through a GitLab App.</div>
|
||||
@if ($gitlab_apps->count() !== 0)
|
||||
<div class="flex flex-col gap-2">
|
||||
@if ($current_step === 'gitlab_apps')
|
||||
<h2 class="pt-4 pb-4">Select a GitLab App</h2>
|
||||
<div class="flex flex-col justify-center gap-2 text-left">
|
||||
@foreach ($gitlab_apps as $glapp)
|
||||
<div class="flex">
|
||||
<div class="w-full gap-2 py-4 group coolbox"
|
||||
wire:click.prevent="loadRepositories({{ $glapp->id }})"
|
||||
wire:key="{{ $glapp->id }}">
|
||||
<div class="flex mr-4">
|
||||
<div class="flex flex-col mx-6">
|
||||
<div class="box-title">
|
||||
{{ data_get($glapp, 'name') }}
|
||||
</div>
|
||||
<div class="box-description">
|
||||
{{ data_get($glapp, 'html_url') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<x-loading wire:loading wire:target="loadRepositories({{ $glapp->id }})" />
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@if ($current_step === 'repository')
|
||||
@if ($repositories->count() > 0)
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.datalist class="w-full" label="Repository" placeholder="Search repositories..." wire:model.live="selected_project_id">
|
||||
@foreach ($repositories as $repo)
|
||||
<option value="{{ data_get($repo, 'id') }}">{{ data_get($repo, 'path_with_namespace') }}</option>
|
||||
@endforeach
|
||||
</x-forms.datalist>
|
||||
</div>
|
||||
<x-forms.button :showLoadingIndicator="false" wire:click.prevent="loadBranches" wire:target="loadBranches, selected_project_id">
|
||||
Load Repository
|
||||
<x-loading-on-button wire:loading.delay wire:target="loadBranches, selected_project_id" />
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@else
|
||||
<div>No repositories found. Check your GitLab App configuration.</div>
|
||||
@endif
|
||||
@if ($branches->count() > 0)
|
||||
<h2 class="text-lg font-bold">Configuration</h2>
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<form class="flex flex-col" wire:submit='submit'>
|
||||
<div class="flex flex-col gap-2 pb-6">
|
||||
<div class="flex gap-2">
|
||||
<x-forms.select id="selected_branch_name" label="Branch">
|
||||
<option value="default" disabled selected>Select a branch</option>
|
||||
@foreach ($branches as $branch)
|
||||
@if ($loop->first)
|
||||
<option selected value="{{ data_get($branch, 'name') }}">
|
||||
{{ data_get($branch, 'name') }}
|
||||
</option>
|
||||
@else
|
||||
<option value="{{ data_get($branch, 'name') }}">
|
||||
{{ data_get($branch, 'name') }}
|
||||
</option>
|
||||
@endif
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
<x-forms.select wire:model.live="build_pack" label="Build Pack" required>
|
||||
<option value="nixpacks">Nixpacks</option>
|
||||
<option value="railpack">Railpack (Beta)</option>
|
||||
<option value="static">Static</option>
|
||||
<option value="dockerfile">Dockerfile</option>
|
||||
<option value="dockercompose">Docker Compose</option>
|
||||
</x-forms.select>
|
||||
@if ($is_static)
|
||||
<x-forms.input id="publish_directory" label="Publish Directory"
|
||||
helper="If there is a build process involved (like Svelte, React, Next, etc..), please specify the output directory for the build assets." />
|
||||
@endif
|
||||
</div>
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: '{{ $base_directory }}',
|
||||
composeLocation: '{{ $docker_compose_location }}',
|
||||
normalizePath(path) {
|
||||
if (!path || path.trim() === '') return '/';
|
||||
path = path.trim();
|
||||
path = path.replace(/\/+$/, '');
|
||||
if (!path.startsWith('/')) {
|
||||
path = '/' + path;
|
||||
}
|
||||
return path;
|
||||
},
|
||||
normalizeBaseDir() {
|
||||
this.baseDir = this.normalizePath(this.baseDir);
|
||||
},
|
||||
normalizeComposeLocation() {
|
||||
this.composeLocation = this.normalizePath(this.composeLocation);
|
||||
}
|
||||
}" class="gap-2 flex flex-col">
|
||||
<x-forms.input placeholder="/" wire:model.defer="base_directory"
|
||||
label="Base Directory"
|
||||
helper="Directory to use as root. Useful for monorepos." x-model="baseDir"
|
||||
@blur="normalizeBaseDir()" />
|
||||
<x-forms.input placeholder="/docker-compose.yaml"
|
||||
wire:model.defer="docker_compose_location" label="Docker Compose Location"
|
||||
helper="It is calculated together with the Base Directory."
|
||||
x-model="composeLocation" @blur="normalizeComposeLocation()" />
|
||||
<div class="pt-2">
|
||||
<span>
|
||||
Compose file location in your repository: </span><span
|
||||
class='dark:text-warning'
|
||||
x-text='(baseDir === "/" ? "" : baseDir) + (composeLocation.startsWith("/") ? composeLocation : "/" + composeLocation)'></span>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<x-forms.input wire:model="base_directory" label="Base Directory"
|
||||
helper="Directory to use as root. Useful for monorepos." />
|
||||
@endif
|
||||
@if ($show_is_static)
|
||||
<x-forms.input type="number" id="port" label="Port" :readonly="$is_static || $build_pack === 'static'"
|
||||
helper="The port your application listens on." />
|
||||
<div class="w-52">
|
||||
<x-forms.checkbox instantSave id="is_static" label="Is it a static site?"
|
||||
helper="If your application is a static site or the final build assets should be served as a static site, enable this." />
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-forms.button type="submit">
|
||||
Continue
|
||||
</x-forms.button>
|
||||
</form>
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="hero">
|
||||
No connected GitLab Application found. Please <a href="/sources" class="underline">add a GitLab App</a> first.
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -6,6 +6,8 @@
|
||||
<livewire:project.new.public-git-repository :type="$type" />
|
||||
@elseif ($type === 'private-gh-app')
|
||||
<livewire:project.new.github-private-repository :type="$type" />
|
||||
@elseif ($type === 'private-gitlab-app')
|
||||
<livewire:project.new.gitlab-private-repository :type="$type" />
|
||||
@elseif ($type === 'private-deploy-key')
|
||||
<livewire:project.new.github-private-repository-deploy-key :type="$type" />
|
||||
@elseif ($type === 'dockerfile')
|
||||
|
||||
@@ -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 > 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
|
||||
@@ -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
@@ -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']);
|
||||
|
||||
@@ -83,6 +83,7 @@ use App\Livewire\SharedVariables\Server\Index as ServerSharedVariablesIndex;
|
||||
use App\Livewire\SharedVariables\Server\Show as ServerSharedVariablesShow;
|
||||
use App\Livewire\SharedVariables\Team\Index as TeamSharedVariablesIndex;
|
||||
use App\Livewire\Source\Github\Change as GitHubChange;
|
||||
use App\Livewire\Source\Gitlab\Change as GitLabChange;
|
||||
use App\Livewire\Storage\Index as StorageIndex;
|
||||
use App\Livewire\Storage\Show as StorageShow;
|
||||
use App\Livewire\Subscription\Index as SubscriptionIndex;
|
||||
@@ -353,6 +354,7 @@ Route::middleware(['auth'])->group(function () {
|
||||
Route::get('/source/github/{github_app_uuid}', GitHubChange::class)->name('source.github.show');
|
||||
Route::get('/source/github/{github_app_uuid}/permissions', GitHubChange::class)->name('source.github.permissions');
|
||||
Route::get('/source/github/{github_app_uuid}/resources', GitHubChange::class)->name('source.github.resources');
|
||||
Route::get('/source/gitlab/{gitlab_app_uuid}', GitLabChange::class)->name('source.gitlab.show');
|
||||
});
|
||||
|
||||
Route::middleware(['auth'])->group(function () {
|
||||
|
||||
@@ -10,11 +10,13 @@ use Illuminate\Support\Facades\Route;
|
||||
Route::middleware(['web', 'auth', 'throttle:30,1'])->group(function () {
|
||||
Route::get('/source/github/redirect', [Github::class, 'redirect']);
|
||||
Route::get('/source/github/install', [Github::class, 'install']);
|
||||
Route::get('/source/gitlab/redirect', [Gitlab::class, 'redirect']);
|
||||
});
|
||||
|
||||
Route::post('/source/github/events', [Github::class, 'normal']);
|
||||
Route::post('/source/github/events/manual', [Github::class, 'manual']);
|
||||
|
||||
Route::post('/source/gitlab/events', [Gitlab::class, 'normal']);
|
||||
Route::post('/source/gitlab/events/manual', [Gitlab::class, 'manual']);
|
||||
|
||||
Route::post('/source/bitbucket/events/manual', [Bitbucket::class, 'manual']);
|
||||
|
||||
@@ -0,0 +1,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");
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use App\Models\GitlabApp;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
});
|
||||
|
||||
it('returns api base url with /api/v4 appended when missing', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('returns api base url unchanged when /api/v4 already present', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('strips trailing slash from api url base', function () {
|
||||
$app = new GitlabApp(['api_url' => 'https://gitlab.example.com/api/v4/']);
|
||||
expect($app->apiUrlBase())->toBe('https://gitlab.example.com/api/v4');
|
||||
});
|
||||
|
||||
it('reports connected when tokens are present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => str_repeat('r', 20), // ggignore
|
||||
]);
|
||||
expect($app->isConnected())->toBeTrue();
|
||||
});
|
||||
|
||||
it('reports not connected when tokens are missing', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => null,
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
|
||||
it('reports not connected when only access token is present', function () {
|
||||
$app = new GitlabApp([
|
||||
'access_token' => str_repeat('t', 20), // ggignore
|
||||
'refresh_token' => null,
|
||||
]);
|
||||
expect($app->isConnected())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
it('redacts oauth2 tokens from deployment logs', function () {
|
||||
$fakeOAuthToken = str_repeat('a', 30); // ggignore
|
||||
$text = "git clone https://oauth2:{$fakeOAuthToken}@gitlab.example.com/group/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeOAuthToken);
|
||||
expect($result)->toContain('oauth2:');
|
||||
});
|
||||
|
||||
it('redacts x-access-token from logs', function () {
|
||||
$fakeToken = str_repeat('x', 40); // ggignore
|
||||
$text = "git clone https://x-access-token:{$fakeToken}@github.com/org/repo.git /app";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
expect($result)->toContain('x-access-token:');
|
||||
});
|
||||
|
||||
it('redacts gitlab personal access tokens', function () {
|
||||
$fakeToken = 'glpat-'.str_repeat('y', 20); // ggignore
|
||||
$text = "Authorization: Bearer {$fakeToken}";
|
||||
$result = remove_iip($text);
|
||||
|
||||
expect($result)->not->toContain($fakeToken);
|
||||
});
|
||||
@@ -3,9 +3,16 @@
|
||||
use App\Models\Application;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
beforeEach(function () {
|
||||
Model::encryptUsing(new Encrypter(str_repeat('a', 32), 'AES-256-CBC'));
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Model::encryptUsing(null);
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
@@ -54,6 +61,9 @@ it('generates ls-remote commands for GitLab source with private key', function (
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn($privateKey);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(1);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('custom_port')->andReturn(22);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -83,6 +93,9 @@ it('generates ls-remote commands for GitLab source without private key', functio
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -112,6 +125,9 @@ it('does not return null for GitLab source type', function () {
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('html_url')->andReturn('https://gitlab.com');
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('privateKey')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('private_key_id')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('access_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('getAttribute')->with('refresh_token')->andReturn(null);
|
||||
$gitlabSource->shouldReceive('isConnected')->andReturn(false);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
@@ -127,3 +143,73 @@ it('does not return null for GitLab source type', function () {
|
||||
expect($lsRemoteResult)->not->toBeNull();
|
||||
expect($lsRemoteResult)->toHaveKeys(['commands', 'branch', 'fullRepoUrl']);
|
||||
});
|
||||
|
||||
it('preserves custom GitLab http port for connected OAuth sources', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'http://gitlab.example.test:8081',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitLsRemoteCommands($deploymentUuid, false);
|
||||
|
||||
expect($result['fullRepoUrl'])
|
||||
->toContain('gitlab.example.test:8081')
|
||||
->toBe('http://oauth2:gitlab-access-token@gitlab.example.test:8081/root/qa-private-app.git');
|
||||
expect($result['commands'])->toContain('gitlab.example.test:8081/root/qa-private-app.git');
|
||||
});
|
||||
|
||||
it('applies OAuth git config to GitLab merge-request fetch and submodule checkout', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
|
||||
$gitlabSource = new GitlabApp([
|
||||
'html_url' => 'https://gitlab.example.test',
|
||||
'access_token' => 'gitlab-access-token',
|
||||
'refresh_token' => 'gitlab-refresh-token',
|
||||
'expires_at' => time() + 3600,
|
||||
]);
|
||||
|
||||
$settings = (object) [
|
||||
'is_git_shallow_clone_enabled' => false,
|
||||
'is_git_submodules_enabled' => true,
|
||||
];
|
||||
|
||||
$application = Mockery::mock(Application::class)->makePartial();
|
||||
$application->git_branch = 'main';
|
||||
$application->shouldReceive('deploymentType')->andReturn('source');
|
||||
$application->shouldReceive('customRepository')->andReturn([
|
||||
'repository' => 'root/qa-private-app',
|
||||
'port' => 22,
|
||||
]);
|
||||
$application->shouldReceive('getAttribute')->with('source')->andReturn($gitlabSource);
|
||||
$application->shouldReceive('getAttribute')->with('settings')->andReturn($settings);
|
||||
$application->source = $gitlabSource;
|
||||
|
||||
$result = $application->generateGitImportCommands(
|
||||
deployment_uuid: $deploymentUuid,
|
||||
pull_request_id: 2,
|
||||
exec_in_docker: false,
|
||||
only_checkout: true,
|
||||
custom_base_dir: '/artifacts/test',
|
||||
);
|
||||
|
||||
$commands = $result['commands'];
|
||||
// The MR-ref fetch and submodule update must run through the OAuth-rewritten git, or private same-host submodules fail.
|
||||
expect($commands)
|
||||
->toContain('oauth2:gitlab-access-token@gitlab.example.test')
|
||||
->toContain("http.version=HTTP/1.1 fetch origin 'merge-requests/2/head:pr-2-coolify'")
|
||||
->toContain('http.version=HTTP/1.1 submodule update --init --recursive');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user