mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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.
301 lines
15 KiB
PHP
301 lines
15 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\Models\Application;
|
|
use App\Models\ApplicationPreview;
|
|
use Exception;
|
|
use Illuminate\Http\Request;
|
|
|
|
class Bitbucket extends Controller
|
|
{
|
|
use DetectsSkipDeployCommits;
|
|
use MatchesManualWebhookApplications;
|
|
use ReadsWebhookPushPayload;
|
|
use ValidatesPreviewDeploymentRepository;
|
|
|
|
public function manual(Request $request)
|
|
{
|
|
try {
|
|
$return_payloads = collect([]);
|
|
$payload = $request->collect();
|
|
$headers = $request->headers->all();
|
|
$x_bitbucket_token = data_get($headers, 'x-hub-signature.0', '');
|
|
$x_bitbucket_event = data_get($headers, 'x-event-key.0', '');
|
|
$handled_events = collect(['repo:push', 'pullrequest:updated', 'pullrequest:created', 'pullrequest:rejected', 'pullrequest:fulfilled']);
|
|
if (! $handled_events->contains($x_bitbucket_event)) {
|
|
return response([
|
|
'status' => 'failed',
|
|
'message' => 'Nothing to do. Event not handled.',
|
|
]);
|
|
}
|
|
if ($x_bitbucket_event === 'repo:push') {
|
|
// A deleted branch has no "new" state, so no branch is found.
|
|
$branch = $this->webhookString(data_get($payload, 'push.changes.0.new.name'));
|
|
$full_name = data_get($payload, 'repository.full_name');
|
|
$commit = $this->webhookCommitSha($payload, 'push.changes.0.new.target.hash');
|
|
// Bitbucket webhooks ship up to 5 commits per change. Larger pushes
|
|
// are evaluated only on the visible 5.
|
|
$changes = data_get($payload, 'push.changes');
|
|
$skip_deploy_commits = self::shouldSkipDeploy(
|
|
collect(is_array($changes) ? $changes : [])
|
|
->flatMap(fn (mixed $change): array => $this->webhookPushCommitMessages($change))
|
|
->values()
|
|
->all()
|
|
);
|
|
|
|
if (! $branch) {
|
|
return response([
|
|
'status' => 'failed',
|
|
'message' => 'Nothing to do. No branch found in the request.',
|
|
]);
|
|
}
|
|
}
|
|
if ($x_bitbucket_event === 'pullrequest:updated' || $x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
|
$branch = $this->webhookString(data_get($payload, 'pullrequest.destination.branch.name'));
|
|
$base_branch = $this->webhookString(data_get($payload, 'pullrequest.source.branch.name'));
|
|
$full_name = data_get($payload, 'repository.full_name');
|
|
$pull_request_id = $this->webhookPullRequestId($payload, 'pullrequest.id');
|
|
$pull_request_html_url = $this->webhookPayloadUrl($payload, 'pullrequest.links.html.href');
|
|
$skip_deploy_pr = self::shouldSkipDeployAny([$this->webhookPayloadString($payload, 'pullrequest.title')]);
|
|
$commit = $this->webhookCommitSha($payload, 'pullrequest.source.commit.hash');
|
|
|
|
if (! $branch) {
|
|
return response([
|
|
'status' => 'failed',
|
|
'message' => 'Nothing to do. No branch found in the request.',
|
|
]);
|
|
}
|
|
}
|
|
$full_name = $this->manualWebhookRepositoryFullName($full_name);
|
|
if ($full_name === null) {
|
|
return response([
|
|
'status' => 'failed',
|
|
'message' => 'Nothing to do. Invalid repository.',
|
|
]);
|
|
}
|
|
$failure_key = $this->manualWebhookFailureRateLimitKey($request, 'bitbucket', $full_name, $branch);
|
|
// A redelivery of the same signed payload is one guess.
|
|
$failure_attempt = $this->manualWebhookSignedPayloadAttempt($request, $x_bitbucket_token);
|
|
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $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_bitbucket');
|
|
if (empty($webhook_secret)) {
|
|
auditLogWebhookFailure('bitbucket', 'webhook_secret_missing', [
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'repository' => $full_name ?? null,
|
|
'event' => $x_bitbucket_event,
|
|
]);
|
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
|
|
|
continue;
|
|
}
|
|
$rawPayload = $request->getContent();
|
|
|
|
$parts = explode('=', $x_bitbucket_token, 2);
|
|
if (count($parts) !== 2 || $parts[0] !== 'sha256') {
|
|
auditLogWebhookFailure('bitbucket', 'malformed_signature', [
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'repository' => $full_name ?? null,
|
|
'event' => $x_bitbucket_event,
|
|
]);
|
|
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
|
|
|
|
continue;
|
|
}
|
|
$hash = $parts[1];
|
|
$payloadHash = hash_hmac('sha256', $rawPayload, $webhook_secret);
|
|
if (! hash_equals($hash, $payloadHash)) {
|
|
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'repository' => $full_name ?? null,
|
|
'event' => $x_bitbucket_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_bitbucket_event === 'repo:push') {
|
|
if ($application->isDeployable()) {
|
|
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,
|
|
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([
|
|
'application' => $application->name,
|
|
'status' => 'skipped',
|
|
'message' => $result['message'],
|
|
]);
|
|
} else {
|
|
auditLog('webhook.deployment.queued', [
|
|
'provider' => 'bitbucket',
|
|
'mode' => 'manual',
|
|
'application_uuid' => $application->uuid,
|
|
'application_name' => $application->name,
|
|
'deployment_uuid' => $deployment_uuid,
|
|
'commit' => $commit,
|
|
'repository' => $full_name ?? null,
|
|
]);
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'success',
|
|
'message' => 'Deployment queued.',
|
|
]);
|
|
}
|
|
} else {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'failed',
|
|
'message' => 'Auto deployment disabled.',
|
|
]);
|
|
}
|
|
}
|
|
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
|
|
if ($application->isPRDeployable()) {
|
|
if (! $this->isPreviewDeploymentRepositoryTrusted(
|
|
data_get($payload, 'pullrequest.source.repository.uuid'),
|
|
data_get($payload, 'pullrequest.destination.repository.uuid'),
|
|
data_get($payload, 'repository.uuid'),
|
|
$application->settings->is_pr_deployments_public_enabled,
|
|
)) {
|
|
continue;
|
|
}
|
|
|
|
if ($skip_deploy_pr ?? false) {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'skipped',
|
|
'message' => 'PR title 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' => 'bitbucket',
|
|
'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' => 'bitbucket',
|
|
'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,
|
|
force_rebuild: false,
|
|
commit: $commit,
|
|
is_webhook: true,
|
|
git_type: 'bitbucket'
|
|
);
|
|
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.',
|
|
]);
|
|
}
|
|
}
|
|
if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
|
$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([
|
|
'application' => $application->name,
|
|
'status' => 'success',
|
|
'message' => 'Preview deployment closed.',
|
|
]);
|
|
} else {
|
|
$return_payloads->push([
|
|
'application' => $application->name,
|
|
'status' => 'failed',
|
|
'message' => 'No preview deployment found.',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|