Merge remote-tracking branch 'origin/next' into jean/allow-dots-username

This commit is contained in:
Andras Bacsai
2026-06-03 11:38:55 +02:00
411 changed files with 21821 additions and 8593 deletions
@@ -5,7 +5,6 @@ namespace App\Http\Controllers\Api;
use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
use App\Actions\Service\StartService;
use App\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
@@ -18,7 +17,7 @@ use App\Models\LocalPersistentVolume;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Rules\DockerImageFormat;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Services\DockerImageParser;
@@ -147,7 +146,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -155,7 +154,7 @@ class ApplicationsController extends Controller
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'git_repository' => ['type' => 'string', 'description' => 'The git repository URL.'],
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -313,7 +312,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -324,7 +323,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -479,7 +478,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -490,7 +489,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -652,7 +651,7 @@ class ApplicationsController extends Controller
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'dockerfile' => ['type' => 'string', 'description' => 'The Dockerfile content.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['dockerfile'], 'description' => 'The build pack type.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
@@ -782,7 +781,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name', 'ports_exposes'],
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -899,105 +898,6 @@ class ApplicationsController extends Controller
return $this->create_application($request, 'dockerimage');
}
/**
* @deprecated Use POST /api/v1/services instead. This endpoint creates a Service, not an Application and is an unstable duplicate of POST /api/v1/services.
*/
#[OA\Post(
summary: 'Create (Docker Compose)',
description: 'Deprecated: Use POST /api/v1/services instead.',
path: '/applications/dockercompose',
operationId: 'create-dockercompose-application',
deprecated: true,
security: [
['bearerAuth' => []],
],
tags: ['Applications'],
requestBody: new OA\RequestBody(
description: 'Application object that needs to be created.',
required: true,
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_compose_raw'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
'environment_name' => ['type' => 'string', 'description' => 'The environment name. You need to provide at least one of environment_name or environment_uuid.'],
'environment_uuid' => ['type' => 'string', 'description' => 'The environment UUID. You need to provide at least one of environment_name or environment_uuid.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The Docker Compose raw content.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID if the server has more than one destinations.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'instant_deploy' => ['type' => 'boolean', 'description' => 'The flag to indicate if the application should be deployed instantly.'],
'use_build_server' => ['type' => 'boolean', 'nullable' => true, 'description' => 'Use build server.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'default' => true, 'description' => 'Escape special characters in labels. By default, $ (and other chars) is escaped. So if you write $ in the labels, it will be saved as $$. If you want to use env variables inside the labels, turn this off.'],
],
)
),
]
),
responses: [
new OA\Response(
response: 201,
description: 'Application created successfully.',
content: new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'uuid' => ['type' => 'string'],
]
)
)
),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
new OA\Response(
response: 409,
description: 'Domain conflicts detected.',
content: [
new OA\MediaType(
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
properties: [
'message' => ['type' => 'string', 'example' => 'Domain conflicts detected. Use force_domain_override=true to proceed.'],
'warning' => ['type' => 'string', 'example' => 'Using the same domain for multiple resources can cause routing conflicts and unpredictable behavior.'],
'conflicts' => [
'type' => 'array',
'items' => new OA\Schema(
type: 'object',
properties: [
'domain' => ['type' => 'string', 'example' => 'example.com'],
'resource_name' => ['type' => 'string', 'example' => 'My Application'],
'resource_uuid' => ['type' => 'string', 'nullable' => true, 'example' => 'abc123-def456'],
'resource_type' => ['type' => 'string', 'enum' => ['application', 'service', 'instance'], 'example' => 'application'],
'message' => ['type' => 'string', 'example' => 'Domain example.com is already in use by application \'My Application\''],
]
),
],
]
)
),
]
),
]
)]
public function create_dockercompose_application(Request $request)
{
return $this->create_application($request, 'dockercompose');
}
private function create_application(Request $request, $type)
{
$teamId = getTeamIdFromToken();
@@ -1080,6 +980,9 @@ class ApplicationsController extends Controller
],
], 422);
}
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
}
$project = Project::whereTeamId($teamId)->whereUuid($request->project_uuid)->first();
@@ -1121,7 +1024,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
@@ -1327,7 +1230,7 @@ class ApplicationsController extends Controller
'git_repository' => 'string|required',
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'github_app_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1567,7 +1470,7 @@ class ApplicationsController extends Controller
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
'git_branch' => ['string', 'required', new ValidGitBranch],
'build_pack' => ['required', Rule::enum(BuildPackTypes::class)],
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
'private_key_uuid' => 'string|required',
'watch_paths' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
@@ -1888,9 +1791,9 @@ class ApplicationsController extends Controller
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
'docker_registry_image_name' => 'string|required',
'docker_registry_image_tag' => 'string',
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|required',
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
@@ -2005,97 +1908,6 @@ class ApplicationsController extends Controller
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
]))->setStatusCode(201);
} elseif ($type === 'dockercompose') {
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override', 'is_container_label_escape_enabled'];
$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);
}
if (! $request->has('name')) {
$request->offsetSet('name', 'service'.new Cuid2);
}
$validationRules = [
'docker_compose_raw' => 'string|required',
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validator = customApiValidator($request->all(), $validationRules);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
return $return;
}
if (! isBase64Encoded($request->docker_compose_raw)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerComposeRaw = base64_decode($request->docker_compose_raw);
if (mb_detect_encoding($dockerComposeRaw, 'UTF-8', true) === false) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
'docker_compose_raw' => 'The docker_compose_raw should be base64 encoded.',
],
], 422);
}
$dockerCompose = base64_decode($request->docker_compose_raw);
$dockerComposeRaw = Yaml::dump(Yaml::parse($dockerCompose), 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
$service = new Service;
removeUnnecessaryFieldsFromRequest($request);
$service->fill($request->only($allowedFields));
$service->docker_compose_raw = $dockerComposeRaw;
$service->environment_id = $environment->id;
$service->server_id = $server->id;
$service->destination_id = $destination->id;
$service->destination_type = $destination->getMorphClass();
if (isset($isContainerLabelEscapeEnabled)) {
$service->is_container_label_escape_enabled = $isContainerLabelEscapeEnabled;
}
$service->save();
$service->parse(isNew: true);
// Apply service-specific application prerequisites
applyServiceApplicationPrerequisites($service);
if ($instantDeploy) {
StartService::dispatch($service);
}
auditLog('api.application.created', [
'team_id' => $teamId,
'service_uuid' => data_get($service, 'uuid'),
'service_name' => data_get($service, 'name'),
'application_type' => $type,
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($service, 'uuid'),
'domains' => data_get($service, 'domains'),
]))->setStatusCode(201);
}
return response()->json(['message' => 'Invalid type.'], 400);
@@ -2398,7 +2210,7 @@ class ApplicationsController extends Controller
'git_branch' => ['type' => 'string', 'description' => 'The git branch.'],
'ports_exposes' => ['type' => 'string', 'description' => 'The ports to expose.'],
'destination_uuid' => ['type' => 'string', 'description' => 'The destination UUID.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'build_pack' => ['type' => 'string', 'enum' => ['nixpacks', 'railpack', 'static', 'dockerfile', 'dockercompose'], 'description' => 'The build pack type.'],
'name' => ['type' => 'string', 'description' => 'The application name.'],
'description' => ['type' => 'string', 'description' => 'The application description.'],
'domains' => ['type' => 'string', 'description' => 'The application URLs in a comma-separated list.'],
@@ -2589,7 +2401,7 @@ class ApplicationsController extends Controller
}
}
}
if ($request->has('custom_nginx_configuration')) {
if ($request->has('custom_nginx_configuration') && ! is_null($request->custom_nginx_configuration)) {
if (! isBase64Encoded($request->custom_nginx_configuration)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2607,6 +2419,9 @@ class ApplicationsController extends Controller
],
], 422);
}
$request->merge([
'custom_nginx_configuration' => $customNginxConfiguration,
]);
}
$return = $this->validateDataApplications($request, $server);
if ($return instanceof JsonResponse) {
@@ -299,6 +299,11 @@ class DatabasesController extends Controller
'mysql_user' => ['type' => 'string', 'description' => 'MySQL user'],
'mysql_database' => ['type' => 'string', 'description' => 'MySQL database'],
'mysql_conf' => ['type' => 'string', 'description' => 'MySQL conf'],
'health_check_enabled' => ['type' => 'boolean', 'description' => 'Enable the database healthcheck probe.', 'default' => true],
'health_check_interval' => ['type' => 'integer', 'description' => 'Healthcheck interval in seconds.', 'minimum' => 1, 'default' => 15],
'health_check_timeout' => ['type' => 'integer', 'description' => 'Healthcheck timeout in seconds.', 'minimum' => 1, 'default' => 5],
'health_check_retries' => ['type' => 'integer', 'description' => 'Healthcheck retries count.', 'minimum' => 1, 'default' => 5],
'health_check_start_period' => ['type' => 'integer', 'description' => 'Healthcheck start period in seconds.', 'minimum' => 0, 'default' => 5],
],
),
)
@@ -565,9 +570,17 @@ class DatabasesController extends Controller
}
break;
}
$allowedFields = array_merge($allowedFields, ['health_check_enabled', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period']);
$healthCheckValidator = customApiValidator($request->all(), [
'health_check_enabled' => 'boolean',
'health_check_interval' => 'integer|min:1',
'health_check_timeout' => 'integer|min:1',
'health_check_retries' => 'integer|min:1',
'health_check_start_period' => 'integer|min:0',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
if ($validator->fails() || $healthCheckValidator->fails() || ! empty($extraFields)) {
$errors = $validator->errors()->merge($healthCheckValidator->errors());
if (! empty($extraFields)) {
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\PushServerUpdateJob;
use App\Models\Server;
use Exception;
use Illuminate\Contracts\Cache\LockTimeoutException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
class SentinelController extends Controller
{
/**
* Handle a Sentinel agent metrics push.
*
* Sentinel pushes its full container list on a fixed interval (default 60s),
* even when nothing changed. To avoid dispatching one PushServerUpdateJob per
* server per minute, the job is only dispatched when the container state hash
* changes, or when the force window has elapsed.
*/
public function push(Request $request)
{
$token = $request->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
try {
$decrypted = decrypt($naked_token);
$decrypted_token = json_decode($decrypted, true);
} catch (Exception $e) {
auditLogWebhookFailure('sentinel', 'decrypt_failed');
return response()->json(['message' => 'Invalid token'], 401);
}
$server_uuid = data_get($decrypted_token, 'server_uuid');
if (! $server_uuid) {
auditLogWebhookFailure('sentinel', 'invalid_token_payload');
return response()->json(['message' => 'Invalid token'], 401);
}
$server = Server::where('uuid', $server_uuid)->first();
if (! $server) {
auditLogWebhookFailure('sentinel', 'server_not_found', [
'server_uuid' => $server_uuid,
]);
return response()->json(['message' => 'Server not found'], 404);
}
if (isCloud() && data_get($server->team->subscription, 'stripe_invoice_paid', false) === false && $server->team->id !== 0) {
auditLogWebhookFailure('sentinel', 'subscription_unpaid', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
if ($server->isFunctional() === false) {
auditLogWebhookFailure('sentinel', 'server_not_functional', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Server is not functional'], 401);
}
if ($server->settings->sentinel_token !== $naked_token) {
auditLogWebhookFailure('sentinel', 'token_mismatch', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'Unauthorized'], 401);
}
$validator = Validator::make($request->all(), [
'containers' => ['present', 'array'],
]);
if ($validator->fails()) {
return response()->json(serializeApiResponse([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
]), 422);
}
$data = $request->all();
// Heartbeat MUST update on every push — drives isSentinelLive() and SSH-check skipping.
$server->sentinelHeartbeat();
if ($this->shouldDispatchUpdate($server, $data)) {
PushServerUpdateJob::dispatch($server, $data);
}
auditLog('sentinel.metrics_pushed', [
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
]);
return response()->json(['message' => 'ok'], 200);
}
/**
* Decide whether PushServerUpdateJob should be dispatched for this push.
*
* Dispatches when: first push (no cached hash), the container state changed,
* or the force window elapsed.
*/
private function shouldDispatchUpdate(Server $server, array $data): bool
{
$hash = $this->containerStateHash($data);
$hashKey = "sentinel:push-hash:{$server->id}";
$forceKey = "sentinel:push-force:{$server->id}";
$lockKey = "sentinel:push-lock:{$server->id}";
try {
return Cache::lock($lockKey, 10)->block(5, function () use ($hashKey, $forceKey, $hash): bool {
$cachedHash = Cache::get($hashKey);
$forceActive = Cache::has($forceKey);
$shouldDispatch = $cachedHash === null || $cachedHash !== $hash || ! $forceActive;
if ($shouldDispatch) {
// Day-long TTL bounds memory if a server stops pushing entirely.
Cache::put($hashKey, $hash, now()->addDay());
Cache::put($forceKey, true, config('constants.sentinel.push_force_interval_seconds', 300));
}
return $shouldDispatch;
});
} catch (LockTimeoutException) {
return false;
}
}
/**
* Build a stable hash of container state.
*
* Covers [name, state] only metrics, filesystem_usage_root, and
* health_status are excluded on purpose. Disk % churns constantly, and
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name
* so container ordering from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
$containers = collect(data_get($data, 'containers', []))
->map(fn ($c) => [
'name' => data_get($c, 'name'),
'state' => data_get($c, 'state'),
])
->sortBy('name')
->values()
->all();
return hash('xxh128', json_encode($containers));
}
}
+38 -10
View File
@@ -7,6 +7,7 @@ use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
use Illuminate\Auth\Events\Verified;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
@@ -98,23 +99,50 @@ class Controller extends BaseController
{
$token = request()->get('token');
if ($token) {
$decrypted = Crypt::decryptString($token);
$email = str($decrypted)->before('@@@');
$password = str($decrypted)->after('@@@');
try {
$decrypted = Crypt::decryptString($token);
} catch (DecryptException) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
if (! str_contains($decrypted, '@@@')) {
return redirect()->route('login')->with('error', 'Invalid credentials.');
}
$payload = explode('@@@', $decrypted, 3);
if (count($payload) === 3) {
[$email, $invitationUuid, $password] = $payload;
} else {
[$email, $password] = $payload;
$invitationUuid = null;
}
$email = Str::lower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
return redirect()->route('login');
}
$invitation = TeamInvitation::query()
->where('email', $email)
->when($invitationUuid, fn ($query) => $query->where('uuid', $invitationUuid))
->where('link', request()->fullUrl())
->first();
if (! $invitation || ! $invitation->isValid()) {
return redirect()->route('login')->with('error', 'Invitation has expired or been revoked.');
}
if (Hash::check($password, $user->password)) {
$invitation = TeamInvitation::whereEmail($email);
if ($invitation->exists()) {
$team = $invitation->first()->team;
$user->teams()->attach($team->id, ['role' => $invitation->first()->role]);
$invitation->delete();
} else {
$team = $user->teams()->first();
$team = $invitation->team;
if (! $user->teams()->where('team_id', $team->id)->exists()) {
$user->teams()->attach($team->id, ['role' => $invitation->role]);
}
$invitation->delete();
Auth::login($user);
$user->forceFill([
'password' => Hash::make(Str::random(64)),
])->save();
session(['currentTeam' => $team]);
return redirect()->route('dashboard');
+13 -17
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
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\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -14,6 +15,7 @@ use Visus\Cuid2\Cuid2;
class Bitbucket extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -62,8 +64,14 @@ class Bitbucket extends Controller
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$applications = $applications->where('git_branch', $branch)->get();
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response([
'status' => 'failed',
'message' => 'Nothing to do. Invalid repository.',
]);
}
$applications = $this->manualWebhookApplications(Application::query()->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response([
'status' => 'failed',
@@ -79,11 +87,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -97,11 +101,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -114,11 +114,7 @@ class Bitbucket extends Controller
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
use App\Models\Application;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
trait MatchesManualWebhookApplications
{
protected function manualWebhookRepositoryFullName(mixed $fullName): ?string
{
if (! is_string($fullName)) {
return null;
}
$fullName = trim($fullName, " \t\n\r\0\x0B/");
if ($fullName === '') {
return null;
}
if (! preg_match('/\A[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\z/', $fullName)) {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($fullName);
}
/**
* @return Collection<int, Application>
*/
protected function manualWebhookApplications(Builder $query, string $fullName): Collection
{
return $query->get()
->filter(fn (Application $application): bool => $this->manualWebhookRepositoryMatches($application->git_repository, $fullName))
->values();
}
protected function manualWebhookRepositoryMatches(?string $gitRepository, string $fullName): bool
{
$repositoryPath = $this->canonicalManualWebhookRepository($gitRepository);
if ($repositoryPath === null) {
return false;
}
// Git hosts (GitHub, GitLab, Gitea, Bitbucket) treat owner/repo names
// case-insensitively, so compare the canonical paths case-insensitively.
return hash_equals(mb_strtolower($fullName), mb_strtolower($repositoryPath));
}
/**
* @return array{status: string, message: string}
*/
protected function unauthenticatedManualWebhookFailurePayload(): array
{
return [
'status' => 'failed',
'message' => 'Invalid signature.',
];
}
protected function canonicalManualWebhookRepository(?string $gitRepository): ?string
{
if (! is_string($gitRepository)) {
return null;
}
$gitRepository = trim($gitRepository);
if ($gitRepository === '') {
return null;
}
$path = null;
$parts = parse_url($gitRepository);
if (is_array($parts) && isset($parts['scheme'])) {
$path = data_get($parts, 'path');
} elseif (Str::startsWith($gitRepository, 'git@') && str_contains($gitRepository, ':')) {
$path = Str::after($gitRepository, ':');
// scp-style SSH URLs embed a custom port as "git@host:2222/owner/repo".
// Strip the leading numeric port segment so the path matches the webhook
// payload's owner/repo, consistent with convertGitUrl() in shared.php.
$path = preg_replace('#^\d+/#', '', $path) ?? $path;
} else {
$path = $gitRepository;
}
if (! is_string($path) || $path === '') {
return null;
}
return $this->normalizeManualWebhookRepositoryPath($path);
}
protected function normalizeManualWebhookRepositoryPath(string $path): string
{
$path = trim($path);
$path = strtok($path, '?#') ?: $path;
$path = trim($path, '/');
$path = preg_replace('/\.git\z/i', '', $path) ?? $path;
return $path;
}
}
+11 -13
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
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\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2;
class Gitea extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -58,15 +60,19 @@ class Gitea extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_gitea_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_gitea_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with branch '$base_branch'.");
}
@@ -80,11 +86,7 @@ class Gitea extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -96,11 +98,7 @@ class Gitea extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
+206 -53
View File
@@ -4,13 +4,17 @@ namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Http\Controllers\Webhook\Concerns\MatchesManualWebhookApplications;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\PrivateKey;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
@@ -18,6 +22,7 @@ use Visus\Cuid2\Cuid2;
class Github extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -59,6 +64,7 @@ class Github extends Controller
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -66,15 +72,19 @@ class Github extends Controller
if (! $branch) {
return response('Nothing to do. No branch found in the request.');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$full_name = $this->manualWebhookRepositoryFullName($full_name);
if ($full_name === null) {
return response('Nothing to do. Invalid repository.');
}
$applications = Application::query();
if ($x_github_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found with deploy key set, branch is '$branch' and Git Repository name has $full_name.");
}
}
if ($x_github_event === 'pull_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
return response("Nothing to do. No applications found for repo $full_name and branch '$base_branch'.");
}
@@ -93,11 +103,7 @@ class Github extends Controller
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -109,11 +115,7 @@ class Github extends Controller
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -223,6 +225,7 @@ class Github extends Controller
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -304,6 +307,7 @@ class Github extends Controller
$before_sha = data_get($payload, 'before');
$after_sha = data_get($payload, 'after', data_get($payload, 'pull_request.head.sha'));
$author_association = data_get($payload, 'pull_request.author_association');
$is_fork_pull_request = $this->isForkPullRequest($payload);
}
if (! in_array($x_github_event, ['push', 'pull_request'])) {
return response("Nothing to do. Event '$x_github_event' is not supported.");
@@ -435,6 +439,7 @@ class Github extends Controller
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
authorAssociation: $author_association,
fullName: $full_name,
isForkPullRequest: $is_fork_pull_request ?? false,
);
$return_payloads->push([
@@ -452,55 +457,203 @@ class Github extends Controller
}
}
/**
* Determine whether a pull_request webhook payload originates from a fork.
*
* GitHub's `author_association` is not a reliable trust signal (it grants
* CONTRIBUTOR to anyone who has merely opened an issue/PR before), so fork
* detection is gated on whether the PR crosses repository boundaries.
*
* The repository id comparison is the canonical signal; the `head.repo.fork`
* flag and a case-insensitive full_name comparison are fallbacks for payloads
* where the ids are unavailable (e.g. a deleted head repository).
*/
private function isForkPullRequest(mixed $payload): bool
{
$headRepoId = data_get($payload, 'pull_request.head.repo.id');
$baseRepoId = data_get($payload, 'pull_request.base.repo.id');
if ($headRepoId !== null && $baseRepoId !== null) {
return (string) $headRepoId !== (string) $baseRepoId;
}
if (data_get($payload, 'pull_request.head.repo.fork') === true) {
return true;
}
$headRepoFullName = data_get($payload, 'pull_request.head.repo.full_name');
$baseRepoFullName = data_get($payload, 'pull_request.base.repo.full_name');
if (is_string($headRepoFullName) && is_string($baseRepoFullName)) {
return Str::lower($headRepoFullName) !== Str::lower($baseRepoFullName);
}
return false;
}
public function redirect(Request $request)
{
try {
$code = $request->get('code');
$state = $request->get('state');
$github_app = GithubApp::where('uuid', $state)->firstOrFail();
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)->accept('application/vnd.github+json')->post("$api_url/app-manifests/$code/conversions")->throw()->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
$code = (string) $request->query('code', '');
abort_if(blank($code), 422, 'Missing GitHub App manifest code.');
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) {
return handleError($e);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'manifest',
);
abort_if($this->githubAppHasManifestCredentials($github_app), 403, 'GitHub App credentials are already configured.');
$api_url = data_get($github_app, 'api_url');
$data = Http::withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
$id = data_get($data, 'id');
$slug = data_get($data, 'slug');
$client_id = data_get($data, 'client_id');
$client_secret = data_get($data, 'client_secret');
$private_key = data_get($data, 'pem');
$webhook_secret = data_get($data, 'webhook_secret');
abort_if(blank($id) || blank($slug) || blank($client_id) || blank($client_secret) || blank($private_key) || blank($webhook_secret), 422, 'GitHub App manifest conversion response is incomplete.');
$private_key = PrivateKey::create([
'name' => "github-app-{$slug}",
'private_key' => $private_key,
'team_id' => $github_app->team_id,
'is_git_related' => true,
]);
$github_app->name = $slug;
$github_app->app_id = $id;
$github_app->client_id = $client_id;
$github_app->client_secret = $client_secret;
$github_app->webhook_secret = $webhook_secret;
$github_app->private_key_id = $private_key->id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
public function install(Request $request)
{
try {
$installation_id = $request->get('installation_id');
$source = $request->get('source');
$setup_action = $request->get('setup_action');
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
if ($setup_action === 'install') {
$github_app->installation_id = $installation_id;
$github_app->save();
}
$setup_action = (string) $request->query('setup_action', '');
abort_unless(in_array($setup_action, ['install', 'update'], true), 422, 'Invalid GitHub App setup action.');
$installation_id = (string) $request->query('installation_id', '');
abort_unless(ctype_digit($installation_id), 422, 'Missing GitHub App installation id.');
if ($setup_action === 'update') {
return $this->redirectAfterGithubAppInstallationUpdate($installation_id);
}
$github_app = $this->consumeGithubAppSetupState(
request: $request,
state: (string) $request->query('state', ''),
action: 'install',
);
abort_unless(
$this->githubInstallationBelongsToApp($github_app, $installation_id),
403,
'GitHub App installation could not be verified.'
);
$github_app->installation_id = $installation_id;
$github_app->save();
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
}
private function redirectAfterGithubAppInstallationUpdate(string $installation_id): RedirectResponse
{
$github_app = GithubApp::ownedByCurrentTeam()
->where('installation_id', $installation_id)
->first();
if ($github_app) {
return redirect()->route('source.github.show', ['github_app_uuid' => $github_app->uuid]);
} catch (Exception $e) {
return handleError($e);
}
return redirect()->route('source.all');
}
/**
* Verify that the given installation id actually belongs to this GitHub App.
*
* The installation id arrives as an untrusted query parameter on an
* unauthenticated-reachable GET callback, so it must be confirmed against
* the GitHub API using the App's own credentials before it is persisted.
*/
private function githubInstallationBelongsToApp(GithubApp $github_app, string $installation_id): bool
{
if (blank($github_app->app_id) || blank($github_app->privateKey?->private_key)) {
return false;
}
try {
$jwt = generateGithubJwt($github_app);
$response = Http::withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
return $response->successful()
&& (string) data_get($response->json(), 'app_id') === (string) $github_app->app_id;
} catch (\Throwable) {
return false;
}
}
private function consumeGithubAppSetupState(Request $request, string $state, string $action): GithubApp
{
if (blank($state)) {
$this->rejectInvalidGithubAppSetupState($request);
}
$payload = Cache::pull($this->githubAppSetupStateCacheKey($state));
if (! is_array($payload) || data_get($payload, 'action') !== $action) {
$this->rejectInvalidGithubAppSetupState($request);
}
$team_id = $request->user()?->currentTeam()?->id;
abort_unless(! is_null($team_id) && (int) data_get($payload, 'team_id') === $team_id, 403);
return GithubApp::whereKey(data_get($payload, 'github_app_id'))
->where('team_id', data_get($payload, 'team_id'))
->firstOrFail();
}
private function rejectInvalidGithubAppSetupState(Request $request): never
{
if ($request->expectsJson()) {
abort(404);
}
throw new HttpResponseException(
redirect()
->route('source.all')
);
}
private function githubAppSetupStateCacheKey(string $state): string
{
return 'github-app-setup-state:'.hash('sha256', $state);
}
private function githubAppHasManifestCredentials(GithubApp $github_app): bool
{
return filled($github_app->app_id)
|| filled($github_app->client_id)
|| filled($github_app->client_secret)
|| filled($github_app->webhook_secret)
|| filled($github_app->private_key_id);
}
}
+16 -13
View File
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Webhook;
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\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@@ -15,6 +16,7 @@ use Visus\Cuid2\Cuid2;
class Gitlab extends Controller
{
use DetectsSkipDeployCommits;
use MatchesManualWebhookApplications;
public function manual(Request $request)
{
@@ -85,9 +87,18 @@ class Gitlab extends Controller
return response($return_payloads);
}
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
$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);
}
$applications = Application::query();
if ($x_gitlab_event === 'push') {
$applications = $applications->where('git_branch', $branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -98,7 +109,7 @@ class Gitlab extends Controller
}
}
if ($x_gitlab_event === 'merge_request') {
$applications = $applications->where('git_branch', $base_branch)->get();
$applications = $this->manualWebhookApplications($applications->where('git_branch', $base_branch), $full_name);
if ($applications->isEmpty()) {
$return_payloads->push([
'status' => 'failed',
@@ -117,11 +128,7 @@ class Gitlab extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Webhook secret not configured.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}
@@ -132,11 +139,7 @@ class Gitlab extends Controller
'repository' => $full_name ?? null,
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
'message' => 'Invalid signature.',
]);
$return_payloads->push($this->unauthenticatedManualWebhookFailurePayload());
continue;
}