Files
coolify/app/Http/Controllers/Webhook/Gitlab.php
T
Andras Bacsai 554bd354bf fix: support host storage paths and clean up cancelled deployments
Allow validated host file and directory mounts outside resource directories
while protecting external paths from deletion.

Clean up cancelled deployment containers and clear tracked process IDs.
Preserve legacy KeyDB and Dragonfly password parsing, accept server backup
paths without extension restrictions, and allow Compose variable proxy ports.
2026-10-05 11:08:52 +02:00

684 lines
33 KiB
PHP

<?php
namespace App\Http\Controllers\Webhook;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Exceptions\InvalidWebhookPayloadException;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
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;
class Gitlab extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
use ReadsWebhookPushPayload;
use ValidatesPreviewDeploymentRepository;
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::GitSource($baseUrl)->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 = $this->webhookString(data_get($payload, 'object_kind'));
$allowed_events = ['push', 'merge_request'];
if (! in_array($object_kind, $allowed_events, true)) {
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);
}
$project_id = $this->webhookPayloadDatabaseId($payload, 'project.id', required: true);
$applications = Application::where('source_id', $gitlab_app->id)
->where('source_type', GitlabApp::class)
->where('repository_project_id', $project_id);
if ($object_kind === 'push') {
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
if (! $branch) {
return response([
'status' => 'failed',
'message' => 'No branch found in the request.',
]);
}
if ($this->isWebhookBranchDeletionPush($payload)) {
return response([
'status' => 'skipped',
'message' => 'Nothing to do. Branch deleted.',
]);
}
$commit = $this->webhookCommitSha($payload, 'after');
$applications = $applications->where('git_branch', $branch)->get();
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
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;
}
if (! $this->webhookPushMatchesWatchPaths($application, $changed_files)) {
$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_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
commit: $commit ?? '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,
'commit' => $commit,
]);
$return_payloads->push([
'application' => $application->name,
'status' => $result['status'] ?? 'success',
'message' => $result['message'] ?? 'Deployment queued.',
]);
}
}
if ($object_kind === 'merge_request') {
$branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch'));
$base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch'));
if (! $branch || ! $base_branch) {
return response([
'status' => 'failed',
'message' => 'No branch found in the request.',
]);
}
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'commit' => $commit,
'skip_deploy' => $skip_deploy_pr,
] = $this->readMergeRequestPayload($payload);
$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'], true)) {
if (! $application->isPRDeployable()) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled',
]);
continue;
}
if (! $this->isPreviewDeploymentRepositoryTrusted(
data_get($payload, 'object_attributes.source_project_id'),
data_get($payload, 'object_attributes.target_project_id'),
data_get($payload, 'project.id'),
$application->settings->is_pr_deployments_public_enabled,
)) {
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_public_id();
$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: $commit ?? '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'], true)) {
$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 (InvalidWebhookPayloadException $e) {
return response([
'status' => 'failed',
'message' => $e->getMessage(),
]);
} catch (Exception $e) {
return handleError($e);
}
}
public function manual(Request $request)
{
try {
$return_payloads = collect([]);
$payload = $request->collect();
$headers = $request->headers->all();
$x_gitlab_token = data_get($headers, 'x-gitlab-token.0');
$x_gitlab_event = $this->webhookString(data_get($payload, 'object_kind'));
$allowed_events = ['push', 'merge_request'];
if (! in_array($x_gitlab_event, $allowed_events, true)) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Event not allowed. Only push and merge_request events are allowed.',
]);
return response($return_payloads);
}
// A delivery without a token does not try a secret, so it is not
// counted as a failed attempt.
if (empty($x_gitlab_token)) {
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
'event' => $x_gitlab_event,
]);
return response([$this->unauthenticatedManualWebhookFailurePayload()]);
}
if ($x_gitlab_event === 'push') {
$branch = $this->webhookPushBranch(data_get($payload, 'ref'));
$full_name = data_get($payload, 'project.path_with_namespace');
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. No branch found in the request.',
]);
return response($return_payloads);
}
// A deleted branch has no commit to deploy. No secret is checked here.
if ($this->isWebhookBranchDeletionPush($payload)) {
$return_payloads->push([
'status' => 'skipped',
'message' => 'Nothing to do. Branch deleted.',
]);
return response($return_payloads);
}
$commit = $this->webhookCommitSha($payload, 'after');
$changed_files = $this->webhookPushChangedFiles($payload);
$skip_deploy_commits = self::shouldSkipDeploy($this->webhookPushCommitMessages($payload));
}
if ($x_gitlab_event === 'merge_request') {
$branch = $this->webhookString(data_get($payload, 'object_attributes.source_branch'));
$base_branch = $this->webhookString(data_get($payload, 'object_attributes.target_branch'));
$full_name = data_get($payload, 'project.path_with_namespace');
if (! $branch || ! $base_branch) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. No branch found in the request.',
]);
return response($return_payloads);
}
[
'action' => $action,
'pull_request_id' => $pull_request_id,
'pull_request_html_url' => $pull_request_html_url,
'commit' => $commit,
'skip_deploy' => $skip_deploy_pr,
] = $this->readMergeRequestPayload($payload);
}
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
$return_payloads->push([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
return response($return_payloads);
}
$matched_branch = $x_gitlab_event === 'merge_request' ? $base_branch : $branch;
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'gitlab', $full_name, $matched_branch);
// GitLab sends a static token. A repeated wrong token is one guess.
$failure_attempt = $this->manualWebhookTokenAttempt($x_gitlab_token);
$applications = Application::query();
if ($x_gitlab_event === 'push') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
}
if ($x_gitlab_event === 'merge_request') {
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return $this->unauthenticatedManualWebhookResponse($failure_key, $failure_attempt);
}
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
if (empty($webhook_secret)) {
auditLogWebhookFailure('gitlab', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
auditLogWebhookFailure('gitlab', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Server is not functional',
]);
continue;
}
if ($x_gitlab_event === 'push') {
if ($application->isDeployable()) {
if ($this->webhookPushMatchesWatchPaths($application, $changed_files)) {
if ($skip_deploy_commits ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'All commits contain [skip cd] or [skip ci]. Skipping deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
continue;
}
$deployment_uuid = new_public_id();
$result = queue_application_deployment(
application: $application,
deployment_uuid: $deployment_uuid,
commit: $commit ?? 'HEAD',
force_rebuild: false,
is_webhook: true,
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitlab',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid,
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
} else {
$paths = str($application->watch_paths)->explode("\n");
$return_payloads->push([
'status' => 'failed',
'message' => 'Changed files do not match watch paths. Ignoring deployment.',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'details' => [
'changed_files' => $changed_files,
'watch_paths' => $paths,
],
]);
}
} else {
$return_payloads->push([
'status' => 'failed',
'message' => 'Deployments disabled',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
}
}
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
if (! $this->isPreviewDeploymentRepositoryTrusted(
data_get($payload, 'object_attributes.source_project_id'),
data_get($payload, 'object_attributes.target_project_id'),
data_get($payload, 'project.id'),
$application->settings->is_pr_deployments_public_enabled,
)) {
continue;
}
if ($skip_deploy_pr ?? false) {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => 'PR title or latest commit contains [skip cd] or [skip ci]. Skipping preview deployment.',
]);
continue;
}
$deployment_uuid = new_public_id();
$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: $commit ?? 'HEAD',
force_rebuild: false,
is_webhook: true,
git_type: 'gitlab'
);
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
} elseif ($result['status'] === 'skipped') {
$return_payloads->push([
'application' => $application->name,
'status' => 'skipped',
'message' => $result['message'],
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
'message' => 'Preview Deployment queued',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Preview deployments disabled',
]);
}
} elseif ($action === 'closed' || $action === 'close' || $action === '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.',
]);
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No preview deployment found.',
]);
}
} else {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'No action found. Contact us for debugging.',
]);
}
}
}
return $this->manualWebhookResponse($return_payloads, $failure_key, $failure_attempt);
} catch (InvalidWebhookPayloadException $e) {
return response([[
'status' => 'failed',
'message' => $e->getMessage(),
]]);
} catch (Exception $e) {
return handleError($e);
}
}
/**
* Read and validate the merge_request payload fields that the handlers use.
*
* @return array{action: ?string, pull_request_id: int, pull_request_html_url: ?string, commit: ?string, skip_deploy: bool}
*
* @throws InvalidWebhookPayloadException When a field has a wrong type or format.
*/
private function readMergeRequestPayload(mixed $payload): array
{
$title = $this->webhookPayloadString($payload, 'object_attributes.title');
return [
'action' => $this->webhookPayloadString($payload, 'object_attributes.action'),
'pull_request_id' => $this->webhookPullRequestId($payload, 'object_attributes.iid'),
'pull_request_html_url' => $this->webhookPayloadUrl($payload, 'object_attributes.url'),
'commit' => $this->webhookCommitSha($payload, 'object_attributes.last_commit.id'),
'skip_deploy' => self::shouldSkipDeployAny([$title, data_get($payload, 'object_attributes.last_commit.message')]),
];
}
}