Files
coolify/bootstrap/helpers/api.php
T
Andras Bacsai 0ed423aaac feat(webhooks): require 16 characters for new manual webhook secrets
New or changed manual webhook secrets (GitHub, GitLab, Bitbucket,
Gitea) must have at least 16 characters, in the Webhooks page and in
the application API. Stored secrets keep working: an unchanged value
passes, and an empty value still disables the webhook. Each secret
field gets a Generate button that fills a random 40-character secret;
the user saves it after adding it to the Git provider.
2026-10-01 20:31:00 +02:00

425 lines
17 KiB
PHP

<?php
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Application;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ManualWebhookSecret;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
function getTeamIdFromToken()
{
$user = auth()->user();
$token = $user?->currentAccessToken();
$teamId = data_get($token, 'team_id');
if (! $user || is_null($teamId) || ! $user->teams()->where('teams.id', $teamId)->exists()) {
return null;
}
return $teamId;
}
function invalidTokenResponse()
{
return response()->json(['message' => 'Invalid token.', 'docs' => 'https://coolify.io/docs/api-reference/authorization'], 400);
}
function serializeApiResponse($data)
{
if ($data instanceof Collection) {
return $data->map(function ($d) {
$d = collect($d)->sortKeys();
$created_at = data_get($d, 'created_at');
$updated_at = data_get($d, 'updated_at');
if ($created_at) {
unset($d['created_at']);
$d['created_at'] = $created_at;
}
if ($updated_at) {
unset($d['updated_at']);
$d['updated_at'] = $updated_at;
}
if (data_get($d, 'name')) {
$d = $d->prepend($d['name'], 'name');
}
if (data_get($d, 'description')) {
$d = $d->prepend($d['description'], 'description');
}
if (data_get($d, 'uuid')) {
$d = $d->prepend($d['uuid'], 'uuid');
}
if (! is_null(data_get($d, 'id'))) {
$d = $d->prepend($d['id'], 'id');
}
return $d;
});
} else {
$d = collect($data)->sortKeys();
$created_at = data_get($d, 'created_at');
$updated_at = data_get($d, 'updated_at');
if ($created_at) {
unset($d['created_at']);
$d['created_at'] = $created_at;
}
if ($updated_at) {
unset($d['updated_at']);
$d['updated_at'] = $updated_at;
}
if (data_get($d, 'name')) {
$d = $d->prepend($d['name'], 'name');
}
if (data_get($d, 'description')) {
$d = $d->prepend($d['description'], 'description');
}
if (data_get($d, 'uuid')) {
$d = $d->prepend($d['uuid'], 'uuid');
}
if (! is_null(data_get($d, 'id'))) {
$d = $d->prepend($d['id'], 'id');
}
return $d;
}
}
/**
* Re-expose a model's `$hidden` sensitive fields when the current API request
* carries the `read:sensitive` or `root` token ability (set by the
* ApiSensitiveData middleware).
*/
function exposeSensitiveFields(Model $model): Model
{
if (request()->attributes->get('can_read_sensitive', false) === true && filled($model->getHidden())) {
$model->makeVisible($model->getHidden());
}
return $model;
}
/**
* Shared validation rules for application create and update API endpoints.
*
* Pass the existing application on update so unchanged manual webhook secrets
* are accepted even when they predate the minimum length.
*/
function sharedDataApplications(?Application $application = null): array
{
return [
'git_repository' => 'string',
'git_branch' => ['string', new ValidGitBranch],
'build_pack' => Rule::enum(BuildPackTypes::class),
'is_static' => 'boolean',
'is_spa' => 'boolean',
'is_auto_deploy_enabled' => 'boolean',
'is_force_https_enabled' => 'boolean',
'is_preview_deployments_enabled' => 'boolean',
'use_build_secrets' => 'boolean',
'is_git_submodules_enabled' => 'boolean',
'is_git_lfs_enabled' => 'boolean',
'is_git_shallow_clone_enabled' => 'boolean',
'disable_build_cache' => 'boolean',
'inject_build_args_to_dockerfile' => 'boolean',
'include_source_commit_in_build' => 'boolean',
'is_env_sorting_enabled' => 'boolean',
'is_pr_deployments_public_enabled' => 'boolean',
'is_gzip_enabled' => 'boolean',
'is_stripprefix_enabled' => 'boolean',
'is_raw_compose_deployment_enabled' => 'boolean',
'is_log_drain_enabled' => 'boolean',
'is_gpu_enabled' => 'boolean',
'gpu_driver' => 'string|nullable',
'gpu_count' => 'string|nullable',
'gpu_device_ids' => 'string|nullable',
'gpu_options' => 'string|nullable',
'is_consistent_container_name_enabled' => 'boolean',
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
'preview_url_template' => 'string',
'max_restart_count' => 'integer|min:0',
'stop_grace_period' => 'nullable|integer|min:'.MIN_STOP_GRACE_PERIOD_SECONDS.'|max:'.MAX_STOP_GRACE_PERIOD_SECONDS,
'docker_images_to_keep' => 'integer|min:0|max:100',
'static_image' => Rule::enum(StaticImageTypes::class),
'domains' => ValidationPatterns::applicationDomainRules(),
'noindex_domains' => 'array|nullable',
'noindex_domains.*' => 'string',
'redirect' => Rule::enum(RedirectTypes::class),
'git_commit_sha' => ['string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'docker_registry_image_name' => ValidationPatterns::dockerImageNameRules(),
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'install_command' => ValidationPatterns::shellSafeCommandRules(),
'build_command' => ValidationPatterns::shellSafeCommandRules(),
'start_command' => ValidationPatterns::shellSafeCommandRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/',
'ports_mappings' => 'string|regex:/^(\d+:\d+)(,\d+:\d+)*$/|nullable',
'custom_network_aliases' => 'string|nullable',
'base_directory' => ValidationPatterns::directoryPathRules(),
'publish_directory' => ValidationPatterns::directoryPathRules(),
'health_check_enabled' => 'boolean',
'health_check_type' => 'string|in:http,cmd',
'health_check_command' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
'health_check_path' => ['string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'health_check_port' => 'integer|nullable|min:1|max:65535',
'health_check_host' => ['string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'health_check_method' => 'string|in:GET,HEAD,POST,OPTIONS',
'health_check_return_code' => 'numeric',
'health_check_scheme' => 'string|in:http,https',
'health_check_response_text' => 'string|nullable',
'health_check_interval' => 'numeric',
'health_check_timeout' => 'numeric',
'health_check_retries' => 'numeric',
'health_check_start_period' => 'numeric',
'limits_memory' => 'string',
'limits_memory_swap' => 'string',
'limits_memory_swappiness' => 'numeric',
'limits_memory_reservation' => 'string',
'limits_cpus' => 'string',
'limits_cpuset' => 'string|nullable',
'limits_cpu_shares' => 'numeric',
'custom_labels' => 'string|nullable',
'custom_docker_run_options' => ValidationPatterns::shellSafeCommandRules(2000),
// Security: deployment commands are intentionally arbitrary shell (e.g. "php artisan migrate").
// Access is gated by API token authentication. Commands run inside the app container, not the host.
'post_deployment_command' => 'string|nullable',
'post_deployment_command_container' => ValidationPatterns::containerNameRules(),
'pre_deployment_command' => 'string|nullable',
'pre_deployment_command_container' => ValidationPatterns::containerNameRules(),
'manual_webhook_secret_github' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_github)],
'manual_webhook_secret_gitlab' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitlab)],
'manual_webhook_secret_bitbucket' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_bitbucket)],
'manual_webhook_secret_gitea' => ['nullable', 'string', new ManualWebhookSecret($application?->manual_webhook_secret_gitea)],
'dockerfile_location' => ValidationPatterns::filePathRules(),
'dockerfile_target_build' => ValidationPatterns::dockerTargetRules(),
'docker_compose_location' => ValidationPatterns::filePathRules(),
'docker_compose' => 'string|nullable',
'docker_compose_domains' => 'array|nullable',
'docker_compose_custom_start_command' => ValidationPatterns::shellSafeCommandRules(),
'docker_compose_custom_build_command' => ValidationPatterns::shellSafeCommandRules(),
'is_container_label_escape_enabled' => 'boolean',
'is_preserve_repository_enabled' => 'boolean',
];
}
function moveResourceToEnvironment(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
$validator = Validator::make($request->all(), [
'environment_uuid' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$extraFields = array_diff(array_keys($request->all()), ['environment_uuid']);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
$newEnvironment = Environment::ownedByCurrentTeamAPI($teamId)
->whereUuid($request->environment_uuid)
->first();
if (! $newEnvironment) {
return response()->json(['message' => 'Target environment not found or not owned by your team.'], 404);
}
Gate::authorize('update', $newEnvironment);
if ($resource->environment_id === $newEnvironment->id) {
return response()->json(['message' => "$resourceType is already in this environment."], 400);
}
$oldEnvironment = $resource->environment()->with('project')->first();
$resource->update(['environment_id' => $newEnvironment->id]);
auditLog('api.'.str($resourceType)->lower()->value().'.moved', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_project_uuid' => $oldEnvironment?->project?->uuid,
'from_environment_uuid' => $oldEnvironment?->uuid,
'to_project_uuid' => $newEnvironment->project->uuid,
'to_environment_uuid' => $newEnvironment->uuid,
]);
return response()->json([
'message' => "$resourceType moved successfully.",
'uuid' => $resource->uuid,
'project_uuid' => $newEnvironment->project->uuid,
'environment_uuid' => $newEnvironment->uuid,
]);
}
function migrateResourceToDestination(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
if (! isDev()) {
abort(404);
}
$validator = Validator::make($request->all(), [
'destination_uuid' => 'required|string',
'migrate_volumes' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$allowedFields = ['destination_uuid', 'migrate_volumes'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
Gate::authorize('update', $resource);
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
if (! $destination || ! $destination->server?->canHostResources()) {
return response()->json(['message' => 'Destination not found.'], 404);
}
$sourceDestination = $resource->destination;
$migrateVolumes = $request->boolean('migrate_volumes', true);
try {
$result = MigrateResourceToDestination::run(
$resource,
$destination,
$migrateVolumes,
);
} catch (ValidationException $e) {
return response()->json([
'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage(),
'errors' => $e->errors(),
], 422);
}
auditLog('api.'.str($resourceType)->lower()->value().'.migrated', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_destination_uuid' => $sourceDestination?->uuid,
'to_destination_uuid' => $destination->uuid,
'from_server_id' => $sourceDestination?->server_id,
'to_server_id' => $destination->server_id,
'migrate_volumes' => $migrateVolumes,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
return response()->json([
'message' => $result['message'],
'uuid' => $resource->uuid,
'destination_uuid' => $destination->uuid,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json
if (! $request->isJson()) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Content-Type must be application/json.',
], 400);
}
// check if request is valid json
if (! json_decode($request->getContent())) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
], 400);
}
// check if valid json is empty
if (empty($request->json()->all())) {
return response()->json([
'message' => 'Invalid request.',
'error' => 'Empty JSON.',
], 400);
}
}
function removeUnnecessaryFieldsFromRequest(Request $request)
{
$request->offsetUnset('project_uuid');
$request->offsetUnset('environment_name');
$request->offsetUnset('environment_uuid');
$request->offsetUnset('destination_uuid');
$request->offsetUnset('server_uuid');
$request->offsetUnset('type');
$request->offsetUnset('domains');
$request->offsetUnset('instant_deploy');
$request->offsetUnset('github_app_uuid');
$request->offsetUnset('private_key_uuid');
$request->offsetUnset('use_build_server');
$request->offsetUnset('use_build_secrets');
$request->offsetUnset('is_static');
$request->offsetUnset('is_spa');
$request->offsetUnset('is_auto_deploy_enabled');
$request->offsetUnset('is_force_https_enabled');
$request->offsetUnset('is_preview_deployments_enabled');
$request->offsetUnset('connect_to_docker_network');
$request->offsetUnset('force_domain_override');
$request->offsetUnset('autogenerate_domain');
$request->offsetUnset('is_container_label_escape_enabled');
$request->offsetUnset('is_preserve_repository_enabled');
$request->offsetUnset('include_source_commit_in_build');
$request->offsetUnset('is_git_submodules_enabled');
$request->offsetUnset('is_git_lfs_enabled');
$request->offsetUnset('is_git_shallow_clone_enabled');
$request->offsetUnset('disable_build_cache');
$request->offsetUnset('inject_build_args_to_dockerfile');
$request->offsetUnset('is_env_sorting_enabled');
$request->offsetUnset('is_pr_deployments_public_enabled');
$request->offsetUnset('stop_grace_period');
$request->offsetUnset('docker_images_to_keep');
$request->offsetUnset('is_gzip_enabled');
$request->offsetUnset('is_stripprefix_enabled');
$request->offsetUnset('is_raw_compose_deployment_enabled');
$request->offsetUnset('is_log_drain_enabled');
$request->offsetUnset('is_gpu_enabled');
$request->offsetUnset('gpu_driver');
$request->offsetUnset('gpu_count');
$request->offsetUnset('gpu_device_ids');
$request->offsetUnset('gpu_options');
$request->offsetUnset('is_consistent_container_name_enabled');
$request->offsetUnset('custom_internal_name');
$request->offsetUnset('custom_container_name_prefix');
$request->offsetUnset('docker_compose_raw');
$request->offsetUnset('tags');
}