Merge branch 'next' into add-emqx-as-a-service-template

This commit is contained in:
Mohmmad Qunibi 2026-05-11 15:40:56 +03:00 committed by GitHub
commit 243d01c228
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 6338 additions and 2292 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,7 @@
## Design Reference
For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
<laravel-boost-guidelines>
=== foundation rules ===

View file

@ -6,6 +6,10 @@ ## Project Overview
Coolify is an open-source, self-hostable PaaS (alternative to Heroku/Netlify/Vercel). It manages servers, applications, databases, and services via SSH. Built with Laravel 12 (using Laravel 10 file structure), Livewire 3, and Tailwind CSS v4.
## Design Reference
For UI/UX design specifications, principles, and visual standards, consult `DESIGN.md` in the [coollabsio/architecture](https://github.com/coollabsio/architecture) repo.
## Development Environment
Docker Compose-based dev setup with services: coolify (app), postgres, redis, soketi (WebSockets), vite, testing-host, mailpit, minio.

View file

@ -69,6 +69,7 @@ ### Big Sponsors
* [Arcjet](https://arcjet.com?ref=coolify.io) - Advanced web security and performance solutions
* [BC Direct](https://bc.direct?ref=coolify.io) - Your trusted technology consulting partner
* [Blacksmith](https://blacksmith.sh?ref=coolify.io) - Infrastructure automation platform
* [Capture.page](https://capture.page/?ref=coolify.io) - Fast & Reliable Screenshot API for Developers
* [Context.dev](https://context.dev?ref=coolify.io) - API to personalize your product with logos, colors, and company info from any domain
* [ByteBase](https://www.bytebase.com?ref=coolify.io) - Database CI/CD and Security at Scale
* [CodeRabbit](https://coderabbit.ai?ref=coolify.io) - Cut Code Review Time & Bugs in Half
@ -87,6 +88,7 @@ ### Big Sponsors
* [Juxtdigital](https://juxtdigital.com?ref=coolify.io) - Digital PR & AI Authority Building Agency
* [LiquidWeb](https://liquidweb.com?ref=coolify.io) - Premium managed hosting solutions
* [Logto](https://logto.io?ref=coolify.io) - The better identity infrastructure for developers
* [LumaDock](https://lumadock.com/vps-hosting/coolify?utm_source=coolify&utm_medium=sponsorship&utm_campaign=coolify_oss_sponsor_2026&utm_content=github_readme) - Fast and reliable virtual server hosting
* [Macarne](https://macarne.com?ref=coolify.io) - Best IP Transit & Carrier Ethernet Solutions for Simplified Network Connectivity
* [Mobb](https://vibe.mobb.ai/?ref=coolify.io) - Secure Your AI-Generated Code to Unlock Dev Productivity
* [PetroSky Cloud](https://petrosky.io?ref=coolify.io) - Open source cloud deployment solutions

View file

@ -4,7 +4,6 @@
use App\Events\SentinelRestarted;
use App\Models\Server;
use App\Models\ServerSetting;
use Lorisleiva\Actions\Concerns\AsAction;
class StartSentinel
@ -23,10 +22,7 @@ public function handle(Server $server, bool $restart = false, ?string $latestVer
$metricsHistory = data_get($server, 'settings.sentinel_metrics_history_days');
$refreshRate = data_get($server, 'settings.sentinel_metrics_refresh_rate_seconds');
$pushInterval = data_get($server, 'settings.sentinel_push_interval_seconds');
$token = data_get($server, 'settings.sentinel_token');
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \RuntimeException('Invalid sentinel token format. Token must contain only alphanumeric characters, dots, hyphens, and underscores.');
}
$token = $server->settings->ensureValidSentinelToken();
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';

View file

@ -4,8 +4,10 @@
use App\Models\InstanceSettings;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Psr\Log\LogLevel;
use RuntimeException;
use Sentry\Laravel\Integration;
use Sentry\State\Scope;
@ -16,7 +18,7 @@ class Handler extends ExceptionHandler
/**
* A list of exception types with their corresponding custom log levels.
*
* @var array<class-string<\Throwable>, \Psr\Log\LogLevel::*>
* @var array<class-string<Throwable>, LogLevel::*>
*/
protected $levels = [
//
@ -25,7 +27,7 @@ class Handler extends ExceptionHandler
/**
* A list of the exception types that are not reported.
*
* @var array<int, class-string<\Throwable>>
* @var array<int, class-string<Throwable>>
*/
protected $dontReport = [
ProcessException::class,
@ -49,6 +51,13 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->is('api/*') || $request->expectsJson() || $this->shouldReturnJson($request, $exception)) {
if ($request->is('api/*')) {
auditLog('api.auth.unauthenticated', [
'reason' => $exception->getMessage(),
'guards' => $exception->guards(),
], 'warning');
}
return response()->json(['message' => $exception->getMessage()], 401);
}
@ -61,8 +70,15 @@ protected function unauthenticated($request, AuthenticationException $exception)
public function render($request, Throwable $e)
{
// Handle authorization exceptions for API routes
if ($e instanceof \Illuminate\Auth\Access\AuthorizationException) {
if ($e instanceof AuthorizationException) {
if ($request->is('api/*') || $request->expectsJson()) {
if ($request->is('api/*')) {
auditLog('api.auth.policy_denied', [
'reason' => $e->getMessage(),
'route' => $request->route()?->getName() ?? $request->path(),
], 'warning');
}
// Get the custom message from the policy if available
$message = $e->getMessage();

View file

@ -71,7 +71,7 @@ public static function establishNewMultiplexedConnection(Server $server): bool
$sshConfig = self::serverSshConfiguration($server);
$sshKeyLocation = $sshConfig['sshKeyLocation'];
$muxSocket = $sshConfig['muxFilename'];
$connectionTimeout = config('constants.ssh.connection_timeout');
$connectionTimeout = self::getConnectionTimeout($server);
$serverInterval = config('constants.ssh.server_interval');
$muxPersistTime = config('constants.ssh.mux_persist_time');
@ -140,7 +140,7 @@ public static function generateScpCommand(Server $server, string $source, string
$scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
}
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true);
$scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
if ($server->isIpv6()) {
$scp_command .= "{$source} ".escapeshellarg($server->user).'@['.escapeshellarg($server->ip)."]:{$dest}";
} else {
@ -184,7 +184,7 @@ public static function generateSshCommand(Server $server, string $command, bool
$ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' ";
}
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'));
$ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'));
$delimiter = Hash::make($command);
$delimiter = base64_encode($delimiter);
@ -243,6 +243,15 @@ private static function validateSshKey(PrivateKey $privateKey): void
}
}
public static function getConnectionTimeout(Server $server): int
{
$timeout = data_get($server, 'settings.connection_timeout');
return is_numeric($timeout) && (int) $timeout > 0
? (int) $timeout
: (int) config('constants.ssh.connection_timeout');
}
private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string
{
$options = "-i {$sshKeyLocation} "

View file

@ -1309,6 +1309,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1539,6 +1548,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1739,6 +1757,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1846,6 +1873,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -1956,6 +1992,15 @@ private function create_application(Request $request, $type)
}
}
auditLog('api.application.created', [
'team_id' => $teamId,
'application_uuid' => data_get($application, 'uuid'),
'application_name' => data_get($application, 'name'),
'application_type' => $type,
'build_pack' => data_get($application, 'build_pack'),
'instant_deploy' => (bool) ($instantDeploy ?? false),
]);
return response()->json(serializeApiResponse([
'uuid' => data_get($application, 'uuid'),
'domains' => data_get($application, 'fqdn'),
@ -2039,6 +2084,14 @@ private function create_application(Request $request, $type)
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'),
@ -2297,6 +2350,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.application.deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
]);
return response()->json([
'message' => 'Application deletion request queued.',
]);
@ -2796,6 +2855,13 @@ public function update_by_uuid(Request $request)
}
$application->save();
auditLog('api.application.updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
if ($instantDeploy) {
$deployment_uuid = new Cuid2;
@ -3048,6 +3114,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@ -3081,6 +3155,14 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.application.env_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
} else {
return response()->json([
@ -3307,6 +3389,12 @@ public function create_bulk_envs(Request $request)
$returnedEnvs->push($this->removeSensitiveData($env));
}
auditLog('api.application.env_bulk_upserted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_count' => $returnedEnvs->count(),
]);
return response()->json($returnedEnvs)->setStatusCode(201);
}
@ -3446,6 +3534,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@ -3471,6 +3567,14 @@ public function create_env(Request $request)
'resourceable_id' => $application->id,
]);
auditLog('api.application.env_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
'is_preview' => (bool) $is_preview,
]);
return response()->json([
'uuid' => $env->uuid,
])->setStatusCode(201);
@ -3562,8 +3666,17 @@ public function delete_env_by_uuid(Request $request)
'message' => 'Environment variable not found.',
], 404);
}
$envKey = $found_env->key;
$envUuid = $found_env->uuid;
$found_env->forceDelete();
auditLog('api.application.env_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json([
'message' => 'Environment variable deleted.',
]);
@ -3675,6 +3788,15 @@ public function action_deploy(Request $request)
);
}
auditLog('api.application.deployed', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'force_rebuild' => $force,
'instant_deploy' => $instant_deploy,
]);
return response()->json(
[
'message' => 'Deployment request queued.',
@ -3763,6 +3885,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopApplication::dispatch($application, false, $dockerCleanup);
auditLog('api.application.stopped', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Application stopping request queued.',
@ -3853,6 +3982,13 @@ public function action_restart(Request $request)
], 200);
}
auditLog('api.application.restarted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
]);
return response()->json(
[
'message' => 'Restart request queued.',
@ -4221,6 +4357,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.application.storage_updated', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -4399,6 +4544,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.application.storage_created', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -4472,8 +4626,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.application.storage_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
@ -4543,6 +4707,12 @@ public function delete_preview_by_pull_request_id(Request $request): JsonRespons
$preview->delete();
CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
auditLog('api.application.preview_deleted', [
'team_id' => $teamId,
'application_uuid' => $application->uuid,
'pull_request_id' => $pullRequestId,
]);
return response()->json(['message' => 'Preview deletion request queued.']);
}
}

View file

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -244,7 +245,7 @@ public function store(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -286,6 +287,13 @@ public function store(Request $request)
'name' => $body['name'],
]);
auditLog('api.cloud_token.created', [
'team_id' => $teamId,
'cloud_token_uuid' => $cloudProviderToken->uuid,
'cloud_token_name' => $cloudProviderToken->name,
'provider' => $cloudProviderToken->provider,
]);
return response()->json([
'uuid' => $cloudProviderToken->uuid,
])->setStatusCode(201);
@ -355,7 +363,7 @@ public function update(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -389,6 +397,14 @@ public function update(Request $request)
$token->update(array_intersect_key($body, array_flip($allowedFields)));
auditLog('api.cloud_token.updated', [
'team_id' => $teamId,
'cloud_token_uuid' => $token->uuid,
'cloud_token_name' => $token->name,
'provider' => $token->provider,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($body))),
]);
return response()->json([
'uuid' => $token->uuid,
]);
@ -464,8 +480,18 @@ public function destroy(Request $request)
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
}
$tokenUuid = $token->uuid;
$tokenName = $token->name;
$tokenProvider = $token->provider;
$token->delete();
auditLog('api.cloud_token.deleted', [
'team_id' => $teamId,
'cloud_token_uuid' => $tokenUuid,
'cloud_token_name' => $tokenName,
'provider' => $tokenProvider,
]);
return response()->json(['message' => 'Cloud provider token deleted.']);
}

View file

@ -596,6 +596,14 @@ public function update_by_uuid(Request $request)
StopDatabaseProxy::dispatch($database);
}
auditLog('api.database.updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'message' => 'Database updated.',
]);
@ -639,10 +647,10 @@ public function update_by_uuid(Request $request)
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@ -703,10 +711,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@ -826,6 +834,15 @@ public function create_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $backupConfig->uuid,
'frequency' => $backupConfig->frequency,
'save_s3' => (bool) $backupConfig->save_s3,
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'uuid' => $backupConfig->uuid,
'message' => 'Backup configuration created successfully.',
@ -878,10 +895,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@ -933,10 +950,10 @@ public function update_backup(Request $request)
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@ -1045,6 +1062,14 @@ public function update_backup(Request $request)
dispatch(new DatabaseBackupJob($backupConfig));
}
auditLog('api.database.backup_updated', [
'team_id' => $teamId,
'backup_uuid' => $backupConfig->uuid,
'database_id' => $backupConfig->database_id,
'changed_fields' => array_values(array_intersect($backupConfigFields, array_keys($request->all()))),
'backup_now' => (bool) $request->backup_now,
]);
return response()->json([
'message' => 'Database backup configuration updated',
]);
@ -1779,6 +1804,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
@ -1838,6 +1873,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
@ -1897,6 +1942,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
@ -1953,6 +2008,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
@ -2039,6 +2104,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
@ -2075,6 +2150,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
} elseif ($type === NewDatabaseTypes::MONGODB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
@ -2133,6 +2218,16 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$payload['external_db_url'] = $database->external_db_url;
}
auditLog('api.database.created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $type->value,
'server_uuid' => $serverUuid,
'is_public' => (bool) $database->is_public,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json(serializeApiResponse($payload))->setStatusCode(201);
}
@ -2217,6 +2312,13 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.database.deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json([
'message' => 'Database deletion request queued.',
]);
@ -2329,6 +2431,14 @@ public function delete_backup_by_uuid(Request $request)
$backup->delete();
DB::commit();
auditLog('api.database.backup_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'delete_s3' => $deleteS3,
'executions_deleted' => $executions->count(),
]);
return response()->json([
'message' => 'Backup configuration and all executions deleted.',
]);
@ -2451,6 +2561,14 @@ public function delete_execution_by_uuid(Request $request)
$execution->delete();
auditLog('api.database.backup_execution_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'backup_uuid' => $request->scheduled_backup_uuid,
'execution_uuid' => $request->execution_uuid,
'delete_s3' => $deleteS3,
]);
return response()->json([
'message' => 'Backup execution deleted.',
]);
@ -2633,6 +2751,13 @@ public function action_deploy(Request $request)
}
StartDatabase::dispatch($database);
auditLog('api.database.started', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database starting request queued.',
@ -2724,6 +2849,14 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopDatabase::dispatch($database, $dockerCleanup);
auditLog('api.database.stopped', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Database stopping request queued.',
@ -2801,6 +2934,13 @@ public function action_restart(Request $request)
RestartDatabase::dispatch($database);
auditLog('api.database.restarted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'database_name' => $database->name,
'database_type' => $database->type(),
]);
return response()->json(
[
'message' => 'Database restarting request queued.',
@ -3017,6 +3157,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.database.env_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@ -3145,6 +3292,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveEnvData($env));
}
auditLog('api.database.env_bulk_upserted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201);
}
@ -3266,6 +3419,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
auditLog('api.database.env_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveEnvData($env))->setStatusCode(201);
}
@ -3351,8 +3511,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete();
auditLog('api.database.env_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']);
}
@ -3599,6 +3768,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.database.storage_created', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -3797,6 +3975,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.database.storage_updated', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -3870,8 +4057,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.database.storage_deleted', [
'team_id' => $teamId,
'database_uuid' => $database->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -281,6 +281,14 @@ public function cancel_deployment(Request $request)
}
}
auditLog('api.deployment.cancelled', [
'team_id' => $teamId,
'deployment_uuid' => $deployment->deployment_uuid,
'application_id' => $application?->id,
'application_uuid' => $application?->uuid,
'server_id' => $deployment->server_id,
]);
return response()->json([
'message' => 'Deployment cancelled successfully.',
'deployment_uuid' => $deployment->deployment_uuid,
@ -518,6 +526,14 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$message = $result['message'];
} else {
$message = "Application {$resource->name} deployment queued.";
auditLog('api.deployment.triggered', [
'resource_type' => 'application',
'application_uuid' => $resource->uuid,
'application_name' => $resource->name,
'deployment_uuid' => $deployment_uuid?->toString(),
'force_rebuild' => $force,
'pull_request_id' => $pr,
]);
}
break;
case Service::class:
@ -529,6 +545,10 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
}
StartService::run($resource);
$message = "Service {$resource->name} started. It could take a while, be patient.";
auditLog('api.service.deployed', [
'service_uuid' => $resource->uuid,
'service_name' => $resource->name,
]);
break;
default:
// Database resource - check authorization
@ -543,6 +563,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0, ?st
$resource->save();
$message = "Database {$resource->name} started.";
auditLog('api.database.started', [
'database_uuid' => $resource->uuid,
'database_name' => $resource->name,
'database_type' => $resource->getMorphClass(),
]);
break;
}

View file

@ -271,6 +271,12 @@ public function create_github_app(Request $request)
$githubApp = GithubApp::create($payload);
auditLog('api.github_app.created', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
]);
return response()->json($githubApp, 201);
} catch (\Throwable $e) {
return handleError($e);
@ -650,6 +656,13 @@ public function update_github_app(Request $request, $github_app_id)
// Update the GitHub app
$githubApp->update($payload);
auditLog('api.github_app.updated', [
'team_id' => $teamId,
'github_app_uuid' => $githubApp->uuid,
'github_app_name' => $githubApp->name,
'changed_fields' => array_values(array_diff($allowedFields, ['client_secret', 'webhook_secret', 'private_key_uuid'])),
]);
return response()->json([
'message' => 'GitHub app updated successfully',
'data' => $githubApp,
@ -734,8 +747,16 @@ public function delete_github_app($github_app_id)
], 409);
}
$deletedUuid = $githubApp->uuid;
$deletedName = $githubApp->name;
$githubApp->delete();
auditLog('api.github_app.deleted', [
'team_id' => $teamId,
'github_app_uuid' => $deletedUuid,
'github_app_name' => $deletedName,
]);
return response()->json([
'message' => 'GitHub app deleted successfully',
]);

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@ -550,7 +552,7 @@ public function createServer(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -717,9 +719,17 @@ public function createServer(Request $request)
// Validate server if requested
if ($request->instant_validate) {
\App\Actions\Server\ValidateServer::dispatch($server);
ValidateServer::dispatch($server);
}
auditLog('api.hetzner_server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'hetzner_server_id' => $hetznerServer['id'],
'ip' => $ipAddress,
]);
return response()->json([
'uuid' => $server->uuid,
'hetzner_server_id' => $hetznerServer['id'],

View file

@ -85,11 +85,15 @@ public function enable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.instance.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => true]);
auditLog('api.instance.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API enabled.'], 200);
}
@ -137,14 +141,130 @@ public function disable_api(Request $request)
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.instance.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the API.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_api_enabled' => false]);
auditLog('api.instance.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'API disabled.'], 200);
}
#[OA\Post(
summary: 'Enable MCP Server',
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/enable',
operationId: 'enable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server enabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server enabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to enable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to enable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function enable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.enable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to enable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => true]);
auditLog('api.mcp.enabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server enabled.'], 200);
}
#[OA\Post(
summary: 'Disable MCP Server',
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).',
path: '/mcp/disable',
operationId: 'disable-mcp',
security: [
['bearerAuth' => []],
],
responses: [
new OA\Response(
response: 200,
description: 'MCP server disabled.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'MCP server disabled.'),
]
)),
new OA\Response(
response: 403,
description: 'You are not allowed to disable the MCP server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'You are not allowed to disable the MCP server.'),
]
)),
new OA\Response(
response: 401,
ref: '#/components/responses/401',
),
new OA\Response(
response: 400,
ref: '#/components/responses/400',
),
]
)]
public function disable_mcp(Request $request)
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
if ($teamId !== '0') {
auditLog('api.mcp.disable_denied', ['team_id' => $teamId], 'warning');
return response()->json(['message' => 'You are not allowed to disable the MCP server.'], 403);
}
$settings = instanceSettings();
$settings->update(['is_mcp_server_enabled' => false]);
auditLog('api.mcp.disabled', ['team_id' => $teamId]);
return response()->json(['message' => 'MCP server disabled.'], 200);
}
public function feedback(Request $request)
{
$data = $request->validate([

View file

@ -264,6 +264,12 @@ public function create_project(Request $request)
'team_id' => $teamId,
]);
auditLog('api.project.created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
]);
return response()->json([
'uuid' => $project->uuid,
])->setStatusCode(201);
@ -382,6 +388,13 @@ public function update_project(Request $request)
$project->update($request->only($allowedFields));
auditLog('api.project.updated', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'project_name' => $project->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $project->uuid,
'name' => $project->name,
@ -460,8 +473,16 @@ public function delete_project(Request $request)
return response()->json(['message' => 'Project has resources, so it cannot be deleted.'], 400);
}
$projectUuid = $project->uuid;
$projectName = $project->name;
$project->delete();
auditLog('api.project.deleted', [
'team_id' => $teamId,
'project_uuid' => $projectUuid,
'project_name' => $projectName,
]);
return response()->json(['message' => 'Project deleted.']);
}
@ -641,6 +662,13 @@ public function create_environment(Request $request)
'name' => $request->name,
]);
auditLog('api.project.environment_created', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $environment->uuid,
'environment_name' => $environment->name,
]);
return response()->json([
'uuid' => $environment->uuid,
])->setStatusCode(201);
@ -723,8 +751,17 @@ public function delete_environment(Request $request)
return response()->json(['message' => 'Environment has resources, so it cannot be deleted.'], 400);
}
$envUuid = $environment->uuid;
$envName = $environment->name;
$environment->delete();
auditLog('api.project.environment_deleted', [
'team_id' => $teamId,
'project_uuid' => $project->uuid,
'environment_uuid' => $envUuid,
'environment_name' => $envName,
]);
return response()->json(['message' => 'Environment deleted.']);
}
}

View file

@ -6,6 +6,7 @@
use App\Models\Application;
use App\Models\ScheduledTask;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@ -33,7 +34,7 @@ private function resolveService(Request $request, int $teamId): ?Service
return Service::whereRelation('environment.project.team', 'id', $teamId)->where('uuid', $request->uuid)->first();
}
private function listTasks(Application|Service $resource): \Illuminate\Http\JsonResponse
private function listTasks(Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@ -44,12 +45,12 @@ private function listTasks(Application|Service $resource): \Illuminate\Http\Json
return response()->json($tasks);
}
private function createTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function createTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -105,15 +106,23 @@ private function createTask(Request $request, Application|Service $resource): \I
$task->save();
auditLog('api.scheduled_task.created', [
'team_id' => $teamId,
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json($this->removeSensitiveData($task), 201);
}
private function updateTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function updateTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
@ -161,22 +170,43 @@ private function updateTask(Request $request, Application|Service $resource): \I
$task->update($request->only($allowedFields));
auditLog('api.scheduled_task.updated', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $task->uuid,
'task_name' => $task->name,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json($this->removeSensitiveData($task), 200);
}
private function deleteTask(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function deleteTask(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('update', $resource);
$deleted = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->delete();
if (! $deleted) {
$task = $resource->scheduled_tasks()->where('uuid', $request->task_uuid)->first();
if (! $task) {
return response()->json(['message' => 'Scheduled task not found.'], 404);
}
$taskUuid = $task->uuid;
$taskName = $task->name;
$task->delete();
auditLog('api.scheduled_task.deleted', [
'team_id' => getTeamIdFromToken(),
'task_uuid' => $taskUuid,
'task_name' => $taskName,
'resource_type' => $resource instanceof Application ? 'application' : 'service',
'resource_uuid' => $resource->uuid,
]);
return response()->json(['message' => 'Scheduled task deleted.']);
}
private function getExecutions(Request $request, Application|Service $resource): \Illuminate\Http\JsonResponse
private function getExecutions(Request $request, Application|Service $resource): JsonResponse
{
$this->authorize('view', $resource);
@ -238,7 +268,7 @@ private function getExecutions(Request $request, Application|Service $resource):
),
]
)]
public function scheduled_tasks_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function scheduled_tasks_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -317,7 +347,7 @@ public function scheduled_tasks_by_application_uuid(Request $request): \Illumina
),
]
)]
public function create_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function create_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -404,7 +434,7 @@ public function create_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function update_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function update_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -474,7 +504,7 @@ public function update_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function delete_scheduled_task_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function delete_scheduled_task_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -542,7 +572,7 @@ public function delete_scheduled_task_by_application_uuid(Request $request): \Il
),
]
)]
public function executions_by_application_uuid(Request $request): \Illuminate\Http\JsonResponse
public function executions_by_application_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -601,7 +631,7 @@ public function executions_by_application_uuid(Request $request): \Illuminate\Ht
),
]
)]
public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function scheduled_tasks_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -680,7 +710,7 @@ public function scheduled_tasks_by_service_uuid(Request $request): \Illuminate\H
),
]
)]
public function create_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function create_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -767,7 +797,7 @@ public function create_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function update_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function update_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -837,7 +867,7 @@ public function update_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function delete_scheduled_task_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function delete_scheduled_task_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -905,7 +935,7 @@ public function delete_scheduled_task_by_service_uuid(Request $request): \Illumi
),
]
)]
public function executions_by_service_uuid(Request $request): \Illuminate\Http\JsonResponse
public function executions_by_service_uuid(Request $request): JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {

View file

@ -232,6 +232,13 @@ public function create_key(Request $request)
'private_key' => $request->private_key,
]);
auditLog('api.private_key.created', [
'team_id' => $teamId,
'private_key_uuid' => $key->uuid,
'private_key_name' => $key->name,
'fingerprint' => $fingerPrint,
]);
return response()->json(serializeApiResponse([
'uuid' => $key->uuid,
]))->setStatusCode(201);
@ -333,6 +340,13 @@ public function update_key(Request $request)
}
$foundKey->update($request->only($allowedFields));
auditLog('api.private_key.updated', [
'team_id' => $teamId,
'private_key_uuid' => $foundKey->uuid,
'private_key_name' => $foundKey->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json(serializeApiResponse([
'uuid' => $foundKey->uuid,
]))->setStatusCode(201);
@ -415,8 +429,16 @@ public function delete_key(Request $request)
], 422);
}
$keyUuid = $key->uuid;
$keyName = $key->name;
$key->forceDelete();
auditLog('api.private_key.deleted', [
'team_id' => $teamId,
'private_key_uuid' => $keyUuid,
'private_key_name' => $keyName,
]);
return response()->json([
'message' => 'Private Key deleted.',
]);

View file

@ -13,6 +13,7 @@
use App\Models\Project;
use App\Models\Server as ModelsServer;
use App\Rules\ValidServerIp;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Stringable;
@ -477,7 +478,7 @@ public function create_server(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@ -564,6 +565,14 @@ public function create_server(Request $request)
ValidateServer::dispatch($server);
}
auditLog('api.server.created', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'ip' => $server->ip,
'is_build_server' => (bool) $request->is_build_server,
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@ -603,6 +612,7 @@ public function create_server(Request $request)
'deployment_queue_limit' => ['type' => 'integer', 'description' => 'Maximum number of queued deployments.'],
'server_disk_usage_notification_threshold' => ['type' => 'integer', 'description' => 'Server disk usage notification threshold (%).'],
'server_disk_usage_check_frequency' => ['type' => 'string', 'description' => 'Cron expression for disk usage check frequency.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds (1-300). Default: 10.'],
],
),
),
@ -639,7 +649,7 @@ public function create_server(Request $request)
)]
public function update_server(Request $request)
{
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency'];
$allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@ -647,7 +657,7 @@ public function update_server(Request $request)
}
$return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) {
if ($return instanceof JsonResponse) {
return $return;
}
$validator = customApiValidator($request->all(), [
@ -665,6 +675,7 @@ public function update_server(Request $request)
'deployment_queue_limit' => 'integer|min:1',
'server_disk_usage_notification_threshold' => 'integer|min:1|max:100',
'server_disk_usage_check_frequency' => 'string',
'connection_timeout' => 'integer|min:1|max:300',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@ -709,7 +720,7 @@ public function update_server(Request $request)
], 422);
}
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency']);
$advancedSettings = $request->only(['concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout']);
if (! empty($advancedSettings)) {
$server->settings()->update(array_filter($advancedSettings, fn ($value) => ! is_null($value)));
}
@ -718,6 +729,13 @@ public function update_server(Request $request)
ValidateServer::dispatch($server);
}
auditLog('api.server.updated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $server->uuid,
])->setStatusCode(201);
@ -807,6 +825,9 @@ public function delete_server(Request $request)
}
}
$deletedUuid = $server->uuid;
$deletedName = $server->name;
$deletedIp = $server->ip;
$server->delete();
DeleteServer::dispatch(
$server->id,
@ -816,6 +837,14 @@ public function delete_server(Request $request)
$server->team_id
);
auditLog('api.server.deleted', [
'team_id' => $teamId,
'server_uuid' => $deletedUuid,
'server_name' => $deletedName,
'ip' => $deletedIp,
'force' => $force,
]);
return response()->json(['message' => 'Server deleted.']);
}
@ -881,6 +910,12 @@ public function validate_server(Request $request)
}
ValidateServer::dispatch($server);
auditLog('api.server.validated', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
'server_name' => $server->name,
]);
return response()->json(['message' => 'Validation started.'], 201);
}
}

View file

@ -486,6 +486,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => $oneClickServiceName ?? null,
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -650,6 +658,14 @@ public function create_service(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'service_type' => 'docker_compose',
'instant_deploy' => (bool) $instantDeploy,
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -792,6 +808,12 @@ public function delete_by_uuid(Request $request)
dockerCleanup: $request->boolean('docker_cleanup', true)
);
auditLog('api.service.deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json([
'message' => 'Service deletion request queued.',
]);
@ -1046,6 +1068,13 @@ public function update_by_uuid(Request $request)
StartService::dispatch($service);
}
auditLog('api.service.updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'changed_fields' => array_values(array_intersect($allowedFields, array_keys($request->all()))),
]);
return response()->json([
'uuid' => $service->uuid,
'domains' => $service->applications()->pluck('fqdn')->filter()->sort()->values(),
@ -1255,6 +1284,13 @@ public function update_env_by_uuid(Request $request)
}
$env->save();
auditLog('api.service.env_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@ -1384,6 +1420,12 @@ public function create_bulk_envs(Request $request)
$updatedEnvs->push($this->removeSensitiveData($env));
}
auditLog('api.service.env_bulk_upserted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_count' => $updatedEnvs->count(),
]);
return response()->json($updatedEnvs)->setStatusCode(201);
}
@ -1506,6 +1548,13 @@ public function create_env(Request $request)
'comment' => $request->comment ?? null,
]);
auditLog('api.service.env_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $env->uuid,
'env_key' => $env->key,
]);
return response()->json($this->removeSensitiveData($env))->setStatusCode(201);
}
@ -1591,8 +1640,17 @@ public function delete_env_by_uuid(Request $request)
return response()->json(['message' => 'Environment variable not found.'], 404);
}
$envKey = $env->key;
$envUuid = $env->uuid;
$env->forceDelete();
auditLog('api.service.env_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'env_uuid' => $envUuid,
'env_key' => $envKey,
]);
return response()->json(['message' => 'Environment variable deleted.']);
}
@ -1668,6 +1726,12 @@ public function action_deploy(Request $request)
}
StartService::dispatch($service);
auditLog('api.service.deployed', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
]);
return response()->json(
[
'message' => 'Service starting request queued.',
@ -1759,6 +1823,13 @@ public function action_stop(Request $request)
$dockerCleanup = $request->boolean('docker_cleanup', true);
StopService::dispatch($service, false, $dockerCleanup);
auditLog('api.service.stopped', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'docker_cleanup' => $dockerCleanup,
]);
return response()->json(
[
'message' => 'Service stopping request queued.',
@ -1846,6 +1917,13 @@ public function action_restart(Request $request)
$pullLatest = $request->boolean('latest');
RestartService::dispatch($service, $pullLatest);
auditLog('api.service.restarted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'pull_latest' => $pullLatest,
]);
return response()->json(
[
'message' => 'Service restarting request queued.',
@ -2126,6 +2204,15 @@ public function create_storage(Request $request): JsonResponse
]);
}
auditLog('api.service.storage_created', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path,
]);
return response()->json($storage, 201);
}
@ -2354,6 +2441,15 @@ public function update_storage(Request $request): JsonResponse
$storage->save();
auditLog('api.service.storage_updated', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storage->uuid ?? null,
'storage_id' => $storage->id,
'storage_type' => $request->type,
'mount_path' => $storage->mount_path ?? null,
]);
return response()->json($storage);
}
@ -2454,8 +2550,18 @@ public function delete_storage(Request $request): JsonResponse
$storage->deleteStorageOnServer();
}
$storageType = $storage instanceof LocalFileVolume ? 'file' : 'persistent';
$storageMountPath = $storage->mount_path ?? null;
$storage->delete();
auditLog('api.service.storage_deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'storage_uuid' => $storageUuid,
'storage_type' => $storageType,
'mount_path' => $storageMountPath,
]);
return response()->json(['message' => 'Storage deleted.']);
}
}

View file

@ -19,7 +19,12 @@ public function callback(string $provider)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
$user = User::whereEmail($oauthUser->email)->first();
$email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([
'name' => $oauthUser->name,
'email' => $oauthUser->email,
'email' => $email,
]);
}
Auth::login($user);

View file

@ -29,6 +29,7 @@ class UploadController extends BaseController
'archive.gz',
'bz2',
'xz',
'dmp',
];
public function upload(Request $request)

View file

@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@ -12,6 +13,8 @@
class Bitbucket extends Controller
{
use DetectsSkipDeployCommits;
public function manual(Request $request)
{
try {
@ -31,6 +34,16 @@ public function manual(Request $request)
$branch = data_get($payload, 'push.changes.0.new.name');
$full_name = data_get($payload, 'repository.full_name');
$commit = data_get($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.
$skip_deploy_commits = self::shouldSkipDeploy(
collect(data_get($payload, 'push.changes', []))
->flatMap(fn ($change) => data_get($change, 'commits', []))
->pluck('message')
->filter()
->values()
->all()
);
if (! $branch) {
return response([
@ -45,6 +58,8 @@ public function manual(Request $request)
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'pullrequest.id');
$pull_request_html_url = data_get($payload, 'pullrequest.links.html.href');
$pull_request_title = data_get($payload, 'pullrequest.title');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$commit = data_get($payload, 'pullrequest.source.commit.hash');
}
$applications = Application::where('git_repository', 'like', "%$full_name%");
@ -58,6 +73,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@ -70,6 +91,12 @@ public function manual(Request $request)
$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([
'application' => $application->name,
'status' => 'failed',
@ -81,6 +108,12 @@ public function manual(Request $request)
$hash = $parts[1];
$payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
auditLogWebhookFailure('bitbucket', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_bitbucket_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -101,6 +134,17 @@ public function manual(Request $request)
}
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@ -118,6 +162,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'bitbucket',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => $commit,
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@ -134,6 +187,15 @@ public function manual(Request $request)
}
if ($x_bitbucket_event === 'pullrequest:created' || $x_bitbucket_event === 'pullrequest:updated') {
if ($application->isPRDeployable()) {
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 Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {

View file

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Webhook\Concerns;
trait DetectsSkipDeployCommits
{
/**
* Returns true if there is at least one non-empty message and every message
* contains [skip cd] or [skip ci] (case-insensitive).
*
* Accepts commit messages from a push payload. Null/empty entries are
* filtered before evaluation.
*
* @param array<int, string|null> $messages
*/
public static function shouldSkipDeploy(array $messages): bool
{
$messages = array_values(array_filter($messages, fn ($m) => filled($m)));
if (empty($messages)) {
return false;
}
foreach ($messages as $message) {
$lower = strtolower((string) $message);
if (! str_contains($lower, '[skip cd]') && ! str_contains($lower, '[skip ci]')) {
return false;
}
}
return true;
}
/**
* Returns true if at least one non-empty message contains [skip cd] or
* [skip ci]. Used for PR/MR title + latest-commit signals where any one
* marker should trigger the skip.
*
* @param array<int, string|null> $messages
*/
public static function shouldSkipDeployAny(array $messages): bool
{
foreach ($messages as $message) {
if (! filled($message)) {
continue;
}
$lower = strtolower((string) $message);
if (str_contains($lower, '[skip cd]') || str_contains($lower, '[skip ci]')) {
return true;
}
}
return false;
}
}

View file

@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@ -13,6 +14,8 @@
class Gitea extends Controller
{
use DetectsSkipDeployCommits;
public function manual(Request $request)
{
try {
@ -40,12 +43,15 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitea_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title]);
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
}
@ -68,6 +74,12 @@ public function manual(Request $request)
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
if (empty($webhook_secret)) {
auditLogWebhookFailure('gitea', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -78,6 +90,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
auditLogWebhookFailure('gitea', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'event' => $x_gitea_event,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -100,6 +118,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@ -117,6 +146,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'gitea',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $deployment_uuid->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@ -149,6 +187,15 @@ public function manual(Request $request)
if ($x_gitea_event === 'pull_request') {
if ($action === 'opened' || $action === 'synchronized' || $action === 'reopened') {
if ($application->isPRDeployable()) {
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 Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {

View file

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Jobs\GithubAppPermissionJob;
use App\Jobs\ProcessGithubPullRequestWebhook;
use App\Models\Application;
@ -16,6 +17,8 @@
class Github extends Controller
{
use DetectsSkipDeployCommits;
public function manual(Request $request)
{
try {
@ -43,12 +46,14 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$full_name = data_get($payload, 'repository.full_name');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@ -82,6 +87,12 @@ public function manual(Request $request)
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -92,6 +103,12 @@ public function manual(Request $request)
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
auditLogWebhookFailure('github', 'invalid_signature', [
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'repository' => $full_name ?? null,
'mode' => 'manual',
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
@ -114,6 +131,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@ -131,6 +159,15 @@ public function manual(Request $request)
'message' => $result['message'],
]);
} else {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'manual',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'application' => $application->name,
'status' => 'success',
@ -180,6 +217,7 @@ public function manual(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),
@ -224,6 +262,13 @@ public function normal(Request $request)
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
auditLogWebhookFailure('github', 'invalid_signature', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
}
@ -246,12 +291,14 @@ public function normal(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_github_event === 'pull_request') {
$action = data_get($payload, 'action');
$id = data_get($payload, 'repository.id');
$pull_request_id = data_get($payload, 'number');
$pull_request_html_url = data_get($payload, 'pull_request.html_url');
$pull_request_title = data_get($payload, 'pull_request.title');
$branch = data_get($payload, 'pull_request.head.ref');
$base_branch = data_get($payload, 'pull_request.base.ref');
$before_sha = data_get($payload, 'before');
@ -300,6 +347,17 @@ public function normal(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@ -311,6 +369,17 @@ public function normal(Request $request)
if ($result['status'] === 'queue_full') {
return response($result['message'], 429)->header('Retry-After', 60);
}
if ($result['status'] !== 'skipped' && ! empty($result['deployment_uuid'])) {
auditLog('webhook.deployment.queued', [
'provider' => 'github',
'mode' => 'app',
'application_uuid' => $application->uuid,
'application_name' => $application->name,
'deployment_uuid' => $result['deployment_uuid'],
'commit' => data_get($payload, 'after'),
'github_app_id' => $github_app->id,
]);
}
$return_payloads->push([
'status' => $result['status'],
'message' => $result['message'],
@ -360,6 +429,7 @@ public function normal(Request $request)
action: $action,
pullRequestId: $pull_request_id,
pullRequestHtmlUrl: $pull_request_html_url,
pullRequestTitle: $pull_request_title ?? null,
beforeSha: $before_sha,
afterSha: $after_sha,
commitSha: data_get($payload, 'pull_request.head.sha', 'HEAD'),

View file

@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use Exception;
@ -13,6 +14,8 @@
class Gitlab extends Controller
{
use DetectsSkipDeployCommits;
public function manual(Request $request)
{
try {
@ -32,6 +35,9 @@ public function manual(Request $request)
}
if (empty($x_gitlab_token)) {
auditLogWebhookFailure('gitlab', 'webhook_token_missing', [
'event' => $x_gitlab_event,
]);
$return_payloads->push([
'status' => 'failed',
'message' => 'Invalid signature.',
@ -58,6 +64,7 @@ public function manual(Request $request)
$removed_files = data_get($payload, 'commits.*.removed');
$modified_files = data_get($payload, 'commits.*.modified');
$changed_files = collect($added_files)->concat($removed_files)->concat($modified_files)->unique()->flatten();
$skip_deploy_commits = self::shouldSkipDeploy(data_get($payload, 'commits.*.message', []));
}
if ($x_gitlab_event === 'merge_request') {
$action = data_get($payload, 'object_attributes.action');
@ -66,6 +73,9 @@ public function manual(Request $request)
$full_name = data_get($payload, 'project.path_with_namespace');
$pull_request_id = data_get($payload, 'object_attributes.iid');
$pull_request_html_url = data_get($payload, 'object_attributes.url');
$pull_request_title = data_get($payload, 'object_attributes.title');
$latest_commit_message = data_get($payload, 'object_attributes.last_commit.message');
$skip_deploy_pr = self::shouldSkipDeployAny([$pull_request_title, $latest_commit_message]);
if (! $branch) {
$return_payloads->push([
'status' => 'failed',
@ -101,6 +111,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@ -110,6 +126,12 @@ public function manual(Request $request)
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([
'application' => $application->name,
'status' => 'failed',
@ -132,6 +154,17 @@ public function manual(Request $request)
if ($application->isDeployable()) {
$is_watch_path_triggered = $application->isWatchPathsTriggered($changed_files);
if ($is_watch_path_triggered || blank($application->watch_paths)) {
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 Cuid2;
$result = queue_application_deployment(
application: $application,
@ -150,6 +183,15 @@ public function manual(Request $request)
'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->toString(),
'commit' => data_get($payload, 'after'),
'repository' => $full_name ?? null,
]);
$return_payloads->push([
'status' => 'success',
'message' => 'Deployment queued.',
@ -182,6 +224,15 @@ public function manual(Request $request)
if ($x_gitlab_event === 'merge_request') {
if ($action === 'open' || $action === 'opened' || $action === 'synchronize' || $action === 'reopened' || $action === 'reopen' || $action === 'update') {
if ($application->isPRDeployable()) {
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 Cuid2;
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
if (! $found) {

View file

@ -6,6 +6,8 @@
use App\Jobs\StripeProcessJob;
use Exception;
use Illuminate\Http\Request;
use Stripe\Exception\SignatureVerificationException;
use Stripe\Webhook;
class Stripe extends Controller
{
@ -14,7 +16,7 @@ public function events(Request $request)
try {
$webhookSecret = config('subscription.stripe_webhook_secret');
$signature = $request->header('Stripe-Signature');
$event = \Stripe\Webhook::constructEvent(
$event = Webhook::constructEvent(
$request->getContent(),
$signature,
$webhookSecret
@ -22,6 +24,12 @@ public function events(Request $request)
StripeProcessJob::dispatch($event);
return response('Webhook received. Cool cool cool cool cool.', 200);
} catch (SignatureVerificationException $e) {
auditLogWebhookFailure('stripe', 'invalid_signature', [
'error' => $e->getMessage(),
]);
return response($e->getMessage(), 400);
} catch (Exception $e) {
return response($e->getMessage(), 400);
}

View file

@ -2,7 +2,40 @@
namespace App\Http;
use App\Http\Middleware\ApiAbility;
use App\Http\Middleware\ApiSensitiveData;
use App\Http\Middleware\Authenticate;
use App\Http\Middleware\CanAccessTerminal;
use App\Http\Middleware\CanCreateResources;
use App\Http\Middleware\CanUpdateResource;
use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Auth\Middleware\AuthenticateWithBasicAuth;
use Illuminate\Auth\Middleware\Authorize;
use Illuminate\Auth\Middleware\EnsureEmailIsVerified;
use Illuminate\Auth\Middleware\RequirePassword;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel;
use Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull;
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
use Illuminate\Http\Middleware\HandleCors;
use Illuminate\Http\Middleware\SetCacheHeaders;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Routing\Middleware\ThrottleRequests;
use Illuminate\Session\Middleware\AuthenticateSession;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Laravel\Sanctum\Http\Middleware\CheckAbilities;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class Kernel extends HttpKernel
{
@ -14,13 +47,13 @@ class Kernel extends HttpKernel
* @var array<int, class-string|string>
*/
protected $middleware = [
\App\Http\Middleware\TrustHosts::class,
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
TrustHosts::class,
TrustProxies::class,
HandleCors::class,
PreventRequestsDuringMaintenance::class,
ValidatePostSize::class,
TrimStrings::class,
ConvertEmptyStringsToNull::class,
];
@ -31,21 +64,21 @@ class Kernel extends HttpKernel
*/
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
\App\Http\Middleware\CheckForcePasswordReset::class,
\App\Http\Middleware\DecideWhatToDoWithUser::class,
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
CheckForcePasswordReset::class,
DecideWhatToDoWithUser::class,
],
'api' => [
// \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
ThrottleRequests::class.':api',
SubstituteBindings::class,
],
];
@ -57,22 +90,23 @@ class Kernel extends HttpKernel
* @var array<string, class-string|string>
*/
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'auth.session' => \Illuminate\Session\Middleware\AuthenticateSession::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'password.confirm' => \Illuminate\Auth\Middleware\RequirePassword::class,
'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class,
'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class,
'api.ability' => \App\Http\Middleware\ApiAbility::class,
'api.sensitive' => \App\Http\Middleware\ApiSensitiveData::class,
'can.create.resources' => \App\Http\Middleware\CanCreateResources::class,
'can.update.resource' => \App\Http\Middleware\CanUpdateResource::class,
'can.access.terminal' => \App\Http\Middleware\CanAccessTerminal::class,
'auth' => Authenticate::class,
'auth.basic' => AuthenticateWithBasicAuth::class,
'auth.session' => AuthenticateSession::class,
'cache.headers' => SetCacheHeaders::class,
'can' => Authorize::class,
'guest' => RedirectIfAuthenticated::class,
'password.confirm' => RequirePassword::class,
'signed' => ValidateSignature::class,
'throttle' => ThrottleRequests::class,
'verified' => EnsureEmailIsVerified::class,
'abilities' => CheckAbilities::class,
'ability' => CheckForAnyAbility::class,
'api.ability' => ApiAbility::class,
'api.sensitive' => ApiSensitiveData::class,
'can.create.resources' => CanCreateResources::class,
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
];
}

View file

@ -2,6 +2,7 @@
namespace App\Http\Middleware;
use Illuminate\Auth\AuthenticationException;
use Laravel\Sanctum\Http\Middleware\CheckForAnyAbility;
class ApiAbility extends CheckForAnyAbility
@ -14,11 +15,22 @@ public function handle($request, $next, ...$abilities)
}
return parent::handle($request, $next, ...$abilities);
} catch (\Illuminate\Auth\AuthenticationException $e) {
} catch (AuthenticationException $e) {
auditLog('api.auth.unauthenticated', [
'reason' => $e->getMessage(),
'required_abilities' => $abilities,
], 'warning');
return response()->json([
'message' => 'Unauthenticated.',
], 401);
} catch (\Exception $e) {
auditLog('api.auth.ability_denied', [
'required_abilities' => $abilities,
'token_id' => $request->user()?->currentAccessToken()?->id,
'reason' => $e->getMessage(),
], 'warning');
return response()->json([
'message' => 'Missing required permissions: '.implode(', ', $abilities),
], 403);

View file

@ -0,0 +1,25 @@
<?php
namespace App\Http\Middleware;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureMcpEnabled
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (! InstanceSettings::get()->is_mcp_server_enabled) {
abort(404);
}
return $next($request);
}
}

View file

@ -3075,29 +3075,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
$safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);

View file

@ -4,6 +4,7 @@
use App\Actions\Application\CleanupPreviewDeployment;
use App\Enums\ProcessStatus;
use App\Http\Controllers\Webhook\Concerns\DetectsSkipDeployCommits;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GithubApp;
@ -17,6 +18,7 @@
class ProcessGithubPullRequestWebhook implements ShouldBeEncrypted, ShouldQueue
{
use DetectsSkipDeployCommits;
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
@ -31,6 +33,7 @@ public function __construct(
public string $action,
public int $pullRequestId,
public string $pullRequestHtmlUrl,
public ?string $pullRequestTitle,
public ?string $beforeSha,
public ?string $afterSha,
public string $commitSha,
@ -83,6 +86,10 @@ private function handleOpenAction(Application $application, ?GithubApp $githubAp
return;
}
if (self::shouldSkipDeployAny([$this->pullRequestTitle])) {
return;
}
// Check if PR deployments from public contributors are restricted
if (! $application->settings->is_pr_deployments_public_enabled) {
$trustedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];

View file

@ -2,6 +2,7 @@
namespace App\Jobs;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
use App\Services\ConfigurationRepository;
@ -43,6 +44,9 @@ private function disableSshMux(): void
public function handle()
{
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
try {
// Check if server is disabled
if ($this->server->settings->force_disabled) {
@ -84,6 +88,8 @@ public function handle()
'server_ip' => $this->server->ip,
]);
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return;
}
@ -99,6 +105,8 @@ public function handle()
$this->server->update(['unreachable_count' => 0]);
}
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, true);
} catch (\Throwable $e) {
Log::error('ServerConnectionCheckJob failed', [
@ -111,6 +119,8 @@ public function handle()
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
return;
}
}
@ -118,17 +128,41 @@ public function handle()
public function failed(?\Throwable $exception): void
{
if ($exception instanceof TimeoutExceededException) {
$wasReachable = (bool) $this->server->settings->is_reachable;
$wasNotified = (bool) $this->server->unreachable_notification_sent;
$this->server->settings->update([
'is_reachable' => false,
'is_usable' => false,
]);
$this->server->increment('unreachable_count');
$this->dispatchReachabilityChangedIfNeeded($wasReachable, $wasNotified, false);
// Delete the queue job so it doesn't appear in Horizon's failed list.
$this->job?->delete();
}
}
/**
* Fire ServerReachabilityChanged when state crosses the unreachable threshold (count >= 2)
* or when a previously-notified server recovers. Skips noise from single transient flaps.
*/
private function dispatchReachabilityChangedIfNeeded(bool $wasReachable, bool $wasNotified, bool $isReachable): void
{
if ($isReachable) {
if (! $wasReachable || $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
return;
}
if ($this->server->unreachable_count >= 2 && ! $wasNotified) {
ServerReachabilityChanged::dispatch($this->server);
}
}
private function checkHetznerStatus(): void
{
$status = null;

View file

@ -45,7 +45,7 @@ class Email extends Component
public ?string $smtpPort = null;
#[Validate(['nullable', 'string', 'in:starttls,tls,none'])]
public ?string $smtpEncryption = null;
public ?string $smtpEncryption = 'starttls';
#[Validate(['nullable', 'string'])]
public ?string $smtpUsername = null;

View file

@ -108,19 +108,6 @@ public function getLogLinesProperty()
return decode_remote_command_output($this->application_deployment_queue);
}
public function copyLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue)
->map(function ($line) {
return $line['timestamp'].' '.
(isset($line['command']) && $line['command'] ? '[CMD]: ' : '').
trim($line['line']);
})
->join("\n");
return sanitizeLogsForExport($logs);
}
public function downloadAllLogs(): string
{
$logs = decode_remote_command_output($this->application_deployment_queue, includeAll: true)

View file

@ -63,13 +63,16 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
$this->syncData();
}
public function syncData(bool $toModel = false): void
{
if ($toModel) {
if ($this->fileStorage->is_too_large) {
return;
}
$this->validate();
// Sync to model
@ -172,6 +175,12 @@ public function submit()
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$original = $this->fileStorage->getOriginal();
try {
$this->validate();
@ -197,6 +206,11 @@ public function submit()
public function instantSave(): void
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
return;
}
$this->syncData(true);
$this->dispatch('success', 'File updated.');
}

View file

@ -69,7 +69,11 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
$this->fileStorage = $this->resource->fileStorages()->get()->each(function (LocalFileVolume $fs) {
if (strlen((string) $fs->content) > LocalFileVolume::MAX_CONTENT_SIZE) {
$fs->content = LocalFileVolume::TOO_LARGE_PLACEHOLDER;
}
});
$this->resource->load('persistentStorages.resource');
}

View file

@ -32,6 +32,8 @@ class Show extends Component
public string $port;
public int $connectionTimeout;
public ?string $validationLogs = null;
public ?string $wildcardDomain = null;
@ -110,6 +112,7 @@ protected function rules(): array
'ip' => ['required', new ValidServerIp],
'user' => ['required', 'regex:/^[a-zA-Z0-9_-]+$/'],
'port' => 'required|integer|between:1,65535',
'connectionTimeout' => 'required|integer|min:1|max:300',
'validationLogs' => 'nullable',
'wildcardDomain' => 'nullable|url',
'isReachable' => 'required',
@ -138,6 +141,10 @@ protected function messages(): array
'ip.required' => 'The IP Address field is required.',
'user.required' => 'The User field is required.',
'port.required' => 'The Port field is required.',
'connectionTimeout.required' => 'The SSH Connection Timeout field is required.',
'connectionTimeout.integer' => 'The SSH Connection Timeout must be an integer.',
'connectionTimeout.min' => 'The SSH Connection Timeout must be at least 1 second.',
'connectionTimeout.max' => 'The SSH Connection Timeout must not exceed 300 seconds.',
'wildcardDomain.url' => 'The Wildcard Domain must be a valid URL.',
'sentinelToken.required' => 'The Sentinel Token field is required.',
'sentinelMetricsRefreshRateSeconds.required' => 'The Metrics Refresh Rate field is required.',
@ -210,6 +217,7 @@ public function syncData(bool $toModel = false)
$this->server->validation_logs = $this->validationLogs;
$this->server->save();
$this->server->settings->connection_timeout = $this->connectionTimeout;
$this->server->settings->is_swarm_manager = $this->isSwarmManager;
$this->server->settings->wildcard_domain = $this->wildcardDomain;
$this->server->settings->is_swarm_worker = $this->isSwarmWorker;
@ -237,6 +245,7 @@ public function syncData(bool $toModel = false)
$this->ip = $this->server->ip;
$this->user = $this->server->user;
$this->port = $this->server->port;
$this->connectionTimeout = $this->server->settings->connection_timeout;
$this->wildcardDomain = $this->server->settings->wildcard_domain;
$this->isReachable = $this->server->settings->is_reachable;
@ -407,7 +416,7 @@ public function checkHetznerServerStatus(bool $manual = false)
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$serverData = $hetznerService->getServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = $serverData['status'] ?? null;
@ -471,7 +480,7 @@ public function startHetznerServer()
return;
}
$hetznerService = new \App\Services\HetznerService($this->server->cloudProviderToken->token);
$hetznerService = new HetznerService($this->server->cloudProviderToken->token);
$hetznerService->powerOnServer($this->server->hetzner_server_id);
$this->hetznerServerStatus = 'starting';

View file

@ -37,6 +37,9 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_wire_navigate_enabled;
#[Validate('boolean')]
public bool $is_mcp_server_enabled;
public function rules()
{
return [
@ -49,6 +52,7 @@ public function rules()
'is_sponsorship_popup_enabled' => 'boolean',
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
}
@ -67,6 +71,7 @@ public function mount()
$this->disable_two_step_confirmation = $this->settings->disable_two_step_confirmation;
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
}
public function submit()
@ -150,6 +155,7 @@ public function instantSave()
$this->settings->is_sponsorship_popup_enabled = $this->is_sponsorship_popup_enabled;
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {

View file

@ -0,0 +1,225 @@
<?php
namespace App\Mcp\Concerns;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
trait BuildsResponse
{
protected int $defaultPerPage = 50;
protected int $maxPerPage = 100;
/**
* Keys removed at any depth from get_* responses.
*
* Covers: raw integer surrogate keys (id and *_id columns; uuid stays),
* Eloquent morph types, encrypted secrets, DB passwords, and bulky
* payloads that should never traverse the MCP boundary.
*
* @var array<int, string>
*/
protected array $sensitiveKeys = [
// raw IDs / morph types (uuid is the public identifier)
'id', 'team_id', 'tokenable_id', 'tokenable_type',
'server_id', 'private_key_id', 'cloud_provider_token_id',
'hetzner_server_id', 'environment_id', 'destination_id',
'source_id', 'repository_project_id', 'application_id',
'service_id', 'project_id', 'parent_id',
'resourceable', 'resourceable_id', 'resourceable_type',
'destination_type', 'source_type', 'tokenable',
// sentinel / observability secrets
'sentinel_token', 'sentinel_custom_url',
'logdrain_newrelic_license_key', 'logdrain_axiom_api_key',
'logdrain_custom_config', 'logdrain_custom_config_parser',
// database passwords
'postgres_password', 'dragonfly_password', 'keydb_password',
'redis_password', 'mongo_initdb_root_password',
'mariadb_password', 'mariadb_root_password',
'mysql_password', 'mysql_root_password',
'clickhouse_admin_password',
// app/env secrets
'value', 'real_value', 'http_basic_auth_password',
// database connection strings embed credentials
'internal_db_url', 'external_db_url', 'init_scripts',
// webhook secrets
'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea',
'manual_webhook_secret_github', 'manual_webhook_secret_gitlab',
// bulky / unsafe blobs
'dockerfile', 'docker_compose', 'docker_compose_raw',
'custom_labels', 'environment_variables',
'environment_variables_preview', 'validation_logs',
'server_metadata',
];
/**
* Recursively remove sensitive keys from any nested array structure.
*
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
protected function scrubSensitive(array $data): array
{
$deny = array_flip($this->sensitiveKeys);
$walk = function ($value) use (&$walk, $deny) {
if (! is_array($value)) {
return $value;
}
$out = [];
foreach ($value as $key => $inner) {
if (is_string($key) && isset($deny[$key])) {
continue;
}
$out[$key] = $walk($inner);
}
return $out;
};
return $walk($data);
}
/**
* @param array<string, mixed>|array<int, mixed> $data
* @param array<int, array<string, mixed>> $actions
* @param array<string, mixed>|null $pagination
*/
protected function respond(array $data, array $actions = [], ?array $pagination = null): Response
{
$payload = ['data' => $data];
if ($actions !== []) {
$payload['_actions'] = $actions;
}
if ($pagination !== null) {
$payload['_pagination'] = $pagination;
}
return Response::json($payload);
}
/**
* @return array{page:int, per_page:int, offset:int}
*/
protected function paginationArgs(Request $request): array
{
$page = max(1, (int) ($request->get('page') ?? 1));
$perPage = (int) ($request->get('per_page') ?? $this->defaultPerPage);
$perPage = max(1, min($this->maxPerPage, $perPage));
return [
'page' => $page,
'per_page' => $perPage,
'offset' => ($page - 1) * $perPage,
];
}
/**
* @param array{page:int, per_page:int, offset:int} $args
* @return array<string, mixed>|null
*/
protected function paginationMeta(string $tool, array $args, int $total, array $extraArgs = []): ?array
{
$page = $args['page'];
$perPage = $args['per_page'];
$totalPages = (int) ceil($total / $perPage);
$meta = [
'page' => $page,
'per_page' => $perPage,
'total' => $total,
'total_pages' => $totalPages,
];
if ($page < $totalPages) {
$meta['next'] = [
'tool' => $tool,
'args' => array_merge($extraArgs, ['page' => $page + 1, 'per_page' => $perPage]),
];
}
return $meta;
}
/**
* HATEOAS-style action suggestions for an application.
*
* @return array<int, array<string, mixed>>
*/
protected function actionsForApplication(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_application', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$s = strtolower((string) $status);
if (str_contains($s, 'running')) {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'application', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForDatabase(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_database', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$s = strtolower((string) $status);
if (str_contains($s, 'running')) {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'database', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForService(string $uuid, ?string $status = null): array
{
$actions = [
['tool' => 'get_service', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
$s = strtolower((string) $status);
if (str_contains($s, 'running')) {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'restart', 'uuid' => $uuid], 'hint' => 'Restart'];
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'stop', 'uuid' => $uuid], 'hint' => 'Stop'];
} else {
$actions[] = ['tool' => 'control', 'args' => ['resource' => 'service', 'action' => 'start', 'uuid' => $uuid], 'hint' => 'Start'];
}
return $actions;
}
/**
* @return array<int, array<string, mixed>>
*/
protected function actionsForServer(string $uuid): array
{
return [
['tool' => 'get_server', 'args' => ['uuid' => $uuid], 'hint' => 'Full details'],
];
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Mcp\Concerns;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
trait ResolvesTeam
{
protected function ensureAbility(Request $request, string $ability = 'read'): ?Response
{
$user = $request->user();
if (! $user) {
return Response::error('Unauthenticated.');
}
$token = $user->currentAccessToken();
if (! $token) {
return Response::error('Invalid token.');
}
if ($token->can('root') || $token->can($ability)) {
return null;
}
return Response::error("Missing required permissions: {$ability}");
}
protected function resolveTeamId(Request $request): ?int
{
$token = $request->user()?->currentAccessToken();
return $token?->team_id;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Mcp\Servers;
use App\Mcp\Tools\GetApplication;
use App\Mcp\Tools\GetDatabase;
use App\Mcp\Tools\GetInfrastructureOverview;
use App\Mcp\Tools\GetServer;
use App\Mcp\Tools\GetService;
use App\Mcp\Tools\ListApplications;
use App\Mcp\Tools\ListDatabases;
use App\Mcp\Tools\ListProjects;
use App\Mcp\Tools\ListServers;
use App\Mcp\Tools\ListServices;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('Coolify')]
#[Version('0.1.0')]
#[Instructions(<<<'MD'
Read-only MCP server for Coolify, scoped to the authenticated team token.
Recommended workflow:
1. get_infrastructure_overview start here; single call returns all servers, projects with resource counts, and aggregates.
2. list_servers / list_projects / list_applications / list_databases / list_services paginated summary listings (default 50 per page, cap 100).
3. get_server / get_application / get_database / get_service full details for a single UUID.
Every response is `{ data, _actions?, _pagination? }`. `_actions` suggests the next tool + args; `_pagination.next` is the args to call again for the next page.
MD)]
class CoolifyServer extends Server
{
protected array $tools = [
GetInfrastructureOverview::class,
ListServers::class,
GetServer::class,
ListProjects::class,
ListApplications::class,
GetApplication::class,
ListDatabases::class,
GetDatabase::class,
ListServices::class,
GetService::class,
];
protected array $resources = [];
protected array $prompts = [];
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_application')]
#[Description('Get full details for a single application by UUID.')]
class GetApplication extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
return Response::error('uuid argument is required.');
}
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $uuid)->first();
if (! $application) {
return Response::error("Application [{$uuid}] not found.");
}
// Drop relations that the server_status accessor lazy-loads — they
// pull in sensitive nested data (server.settings.sentinel_token, etc.)
$application->setRelations([]);
$application->makeHidden(['destination', 'source', 'additional_servers', 'environment', 'tags', 'environmentVariables']);
return $this->respond(
$this->scrubSensitive($application->toArray()),
$this->actionsForApplication($uuid, $application->status),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Application UUID.')->required(),
];
}
}

View file

@ -0,0 +1,58 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_database')]
#[Description('Get full details for a standalone database by UUID. Detects type across postgresql, mysql, mariadb, mongodb, redis, keydb, dragonfly, clickhouse.')]
class GetDatabase extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
return Response::error('uuid argument is required.');
}
$database = queryDatabaseByUuidWithinTeam($uuid, (string) $teamId);
if (! $database) {
return Response::error("Database [{$uuid}] not found.");
}
// Drop relations so deep server/destination data doesn't leak.
$database->setRelations([]);
$database->makeHidden(['destination', 'source', 'environment', 'environment_variables', 'environment_variables_preview']);
return $this->respond(
$this->scrubSensitive($database->toArray()),
$this->actionsForDatabase($uuid, $database->status ?? null),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Database UUID.')->required(),
];
}
}

View file

@ -0,0 +1,93 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_infrastructure_overview')]
#[Description('High-level overview of the authenticated team: Coolify version, all servers, projects with resource counts, and aggregate counts. Start here to understand the setup.')]
class GetInfrastructureOverview extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$servers = Server::whereTeamId($teamId)
->select('id', 'name', 'uuid', 'ip', 'description')
->with('settings:id,server_id,is_reachable,is_usable')
->get()
->map(fn ($s) => [
'uuid' => $s->uuid,
'name' => $s->name,
'ip' => $s->ip,
'is_reachable' => $s->settings?->is_reachable,
'is_usable' => $s->settings?->is_usable,
])
->values()
->all();
$projects = Project::where('team_id', $teamId)->get();
$appCount = 0;
$serviceCount = 0;
$databaseCount = 0;
$projectSummaries = [];
foreach ($projects as $project) {
$apps = $project->applications()->count();
$services = $project->services()->count();
$databases = $project->databases()->count();
$appCount += $apps;
$serviceCount += $services;
$databaseCount += $databases;
$projectSummaries[] = [
'uuid' => $project->uuid,
'name' => $project->name,
'counts' => [
'applications' => $apps,
'services' => $services,
'databases' => $databases,
],
];
}
return $this->respond([
'coolify_version' => config('constants.coolify.version'),
'servers' => $servers,
'projects' => $projectSummaries,
'counts' => [
'servers' => count($servers),
'projects' => count($projectSummaries),
'applications' => $appCount,
'services' => $serviceCount,
'databases' => $databaseCount,
],
]);
}
public function schema(JsonSchema $schema): array
{
return [];
}
}

View file

@ -0,0 +1,57 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_server')]
#[Description('Get full details for a single server by UUID.')]
class GetServer extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
return Response::error('uuid argument is required.');
}
$server = Server::whereTeamId($teamId)->where('uuid', $uuid)->with('settings')->first();
if (! $server) {
return Response::error("Server [{$uuid}] not found.");
}
$data = $this->scrubSensitive($server->toArray());
$data['is_reachable'] = $server->settings?->is_reachable;
$data['is_usable'] = $server->settings?->is_usable;
$data['connection_timeout'] = $server->settings?->connection_timeout;
return $this->respond($data, $this->actionsForServer($uuid));
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Server UUID.')->required(),
];
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('get_service')]
#[Description('Get full details for a single service (multi-container stack) by UUID.')]
class GetService extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$uuid = $request->get('uuid');
if (! is_string($uuid) || $uuid === '') {
return Response::error('uuid argument is required.');
}
$service = Service::whereRelation('environment.project.team', 'id', $teamId)
->where('uuid', $uuid)
->first();
if (! $service) {
return Response::error("Service [{$uuid}] not found.");
}
$service->setRelations([]);
$service->makeHidden(['destination', 'source', 'environment', 'applications', 'databases', 'serviceApplications', 'serviceDatabases']);
return $this->respond(
$this->scrubSensitive($service->toArray()),
$this->actionsForService($uuid, $service->status ?? null),
);
}
public function schema(JsonSchema $schema): array
{
return [
'uuid' => $schema->string()->description('Service UUID.')->required(),
];
}
}

View file

@ -0,0 +1,77 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Application;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_applications')]
#[Description('List applications owned by the authenticated team. Returns summary (uuid, name, status, fqdn, git_repository). Optional "tag" argument filters by tag name. Use get_application for full details.')]
class ListApplications extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$tagName = $request->get('tag');
if ($tagName !== null && (! is_string($tagName) || trim($tagName) === '')) {
return Response::error('tag argument must be a non-empty string.');
}
$args = $this->paginationArgs($request);
$query = Application::ownedByCurrentTeamAPI($teamId)
->when($tagName !== null, function ($query) use ($tagName) {
$query->whereHas('tags', fn ($q) => $q->where('name', $tagName));
});
$total = (clone $query)->count();
$summaries = $query
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($app) => [
'uuid' => $app->uuid,
'name' => $app->name,
'status' => $app->status,
'fqdn' => $app->fqdn,
'git_repository' => $app->git_repository,
])
->values()
->all();
$extra = $tagName ? ['tag' => $tagName] : [];
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_applications', $args, $total, $extra),
);
}
public function schema(JsonSchema $schema): array
{
return [
'tag' => $schema->string()->description('Optional tag name filter.'),
'page' => $schema->integer()->description('Page number (default 1).'),
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
];
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_databases')]
#[Description('List standalone databases owned by the authenticated team. Returns summary (uuid, name, status, type). Use get_database for full details.')]
class ListDatabases extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$projects = Project::where('team_id', $teamId)->get();
$databases = collect();
foreach ($projects as $project) {
$databases = $databases->merge($project->databases());
}
$total = $databases->count();
$summaries = $databases
->sortBy('name')
->slice($args['offset'], $args['per_page'])
->map(fn ($db) => [
'uuid' => $db->uuid,
'name' => $db->name,
'status' => $db->status ?? null,
'type' => method_exists($db, 'type') ? $db->type() : class_basename($db),
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_databases', $args, $total),
);
}
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->description('Page number (default 1).'),
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Project;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_projects')]
#[Description('List projects owned by the authenticated team. Returns summary (uuid, name, description).')]
class ListProjects extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$query = Project::whereTeamId($teamId);
$total = (clone $query)->count();
$summaries = $query
->select('name', 'description', 'uuid')
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($p) => [
'uuid' => $p->uuid,
'name' => $p->name,
'description' => $p->description,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_projects', $args, $total),
);
}
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->description('Page number (default 1).'),
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
];
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Server;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_servers')]
#[Description('List servers visible to the authenticated team token. Returns summary (uuid, name, ip, reachability). Use get_server for full details.')]
class ListServers extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$query = Server::whereTeamId($teamId)->with('settings:id,server_id,is_reachable,is_usable');
$total = (clone $query)->count();
$summaries = $query
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($s) => [
'uuid' => $s->uuid,
'name' => $s->name,
'ip' => $s->ip,
'is_reachable' => $s->settings?->is_reachable,
'is_usable' => $s->settings?->is_usable,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_servers', $args, $total),
);
}
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->description('Page number (default 1).'),
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
];
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Mcp\Tools;
use App\Mcp\Concerns\BuildsResponse;
use App\Mcp\Concerns\ResolvesTeam;
use App\Models\Service;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
#[Name('list_services')]
#[Description('List services (multi-container stacks) owned by the authenticated team. Returns summary (uuid, name, status). Use get_service for full details.')]
class ListServices extends Tool
{
use BuildsResponse;
use ResolvesTeam;
public function handle(Request $request): Response
{
if ($error = $this->ensureAbility($request, 'read')) {
return $error;
}
$teamId = $this->resolveTeamId($request);
if (is_null($teamId)) {
return Response::error('Invalid token.');
}
$args = $this->paginationArgs($request);
$query = Service::whereHas('environment.project', fn ($q) => $q->where('team_id', $teamId));
$total = (clone $query)->count();
$summaries = $query
->orderBy('name')
->skip($args['offset'])
->take($args['per_page'])
->get()
->map(fn ($svc) => [
'uuid' => $svc->uuid,
'name' => $svc->name,
'status' => $svc->status ?? null,
])
->values()
->all();
return $this->respond(
$summaries,
[],
$this->paginationMeta('list_services', $args, $total),
);
}
public function schema(JsonSchema $schema): array
{
return [
'page' => $schema->integer()->description('Page number (default 1).'),
'per_page' => $schema->integer()->description('Items per page (default 50, max 100).'),
];
}
}

View file

@ -45,6 +45,7 @@ class InstanceSettings extends Model
'is_sponsorship_popup_enabled',
'dev_helper_version',
'is_wire_navigate_enabled',
'is_mcp_server_enabled',
];
protected $casts = [
@ -67,6 +68,7 @@ class InstanceSettings extends Model
'update_check_frequency' => 'string',
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
protected static function booted(): void

View file

@ -10,6 +10,12 @@
class LocalFileVolume extends BaseModel
{
public const MAX_CONTENT_SIZE = 5_242_880;
public const BINARY_PLACEHOLDER = '[binary file]';
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@ -33,7 +39,7 @@ class LocalFileVolume extends BaseModel
'is_preview_suffix_enabled',
];
public $appends = ['is_binary'];
public $appends = ['is_binary', 'is_too_large'];
protected static function booted()
{
@ -46,9 +52,14 @@ protected static function booted()
protected function isBinary(): Attribute
{
return Attribute::make(
get: function () {
return $this->content === '[binary file]';
}
get: fn () => $this->content === self::BINARY_PLACEHOLDER
);
}
protected function isTooLarge(): Attribute
{
return Attribute::make(
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
);
}
@ -81,10 +92,17 @@ public function loadStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK') {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
$this->is_directory = false;
$this->save();
return;
}
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
// Check if content contains binary data by looking for null bytes or non-printable characters
if (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content)) {
$content = '[binary file]';
$content = self::BINARY_PLACEHOLDER;
}
$this->content = $content;
$this->is_directory = false;
@ -92,6 +110,18 @@ public function loadStorageOnServer()
}
}
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
{
$sizeOutput = instant_remote_process(
["stat -c%s {$escapedPath} 2>/dev/null || wc -c < {$escapedPath}"],
$server,
false,
);
$size = (int) trim((string) $sizeOutput);
return $size > self::MAX_CONTENT_SIZE;
}
public function deleteStorageOnServer()
{
$this->load(['service']);
@ -173,9 +203,12 @@ public function saveStorageOnServer()
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
if ($isFile === 'OK' && $this->is_directory) {
$content = instant_remote_process(["cat {$escapedPath}"], $server, false);
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
$this->content = instant_remote_process(["cat {$escapedPath}"], $server, false);
}
$this->is_directory = false;
$this->content = $content;
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');

View file

@ -76,20 +76,14 @@ public function executions(): HasMany
return $this->hasMany(ScheduledTaskExecution::class)->orderBy('created_at', 'desc');
}
public function server()
public function server(): ?Server
{
if ($this->application) {
if ($this->application->destination && $this->application->destination->server) {
return $this->application->destination->server;
}
} elseif ($this->service) {
if ($this->service->destination && $this->service->destination->server) {
return $this->service->destination->server;
}
} elseif ($this->database) {
if ($this->database->destination && $this->database->destination->server) {
return $this->database->destination->server;
}
return $this->application->destination?->server;
}
if ($this->service) {
return $this->service->destination?->server;
}
return null;

View file

@ -1236,10 +1236,8 @@ public function isReachableChanged()
$this->refresh();
$unreachableNotificationSent = (bool) $this->unreachable_notification_sent;
$isReachable = (bool) $this->settings->is_reachable;
if ($isReachable === true) {
$this->unreachable_count = 0;
$this->save();
if ($isReachable === true) {
if ($unreachableNotificationSent === true) {
$this->sendReachableNotification();
}
@ -1247,28 +1245,8 @@ public function isReachableChanged()
return;
}
$this->increment('unreachable_count');
if ($this->unreachable_count === 1) {
$this->settings->is_reachable = true;
$this->settings->save();
return;
}
if ($this->unreachable_count >= 2 && ! $unreachableNotificationSent) {
$failedChecks = 0;
for ($i = 0; $i < 3; $i++) {
$status = $this->serverStatus();
sleep(5);
if (! $status) {
$failedChecks++;
}
}
if ($failedChecks === 3 && ! $unreachableNotificationSent) {
$this->sendUnreachableNotification();
}
$this->sendUnreachableNotification();
}
}

View file

@ -2,6 +2,7 @@
namespace App\Models;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Log;
@ -49,6 +50,7 @@
'updated_at' => ['type' => 'string'],
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
]
)]
class ServerSetting extends Model
@ -97,6 +99,7 @@ class ServerSetting extends Model
'is_terminal_enabled',
'deployment_queue_limit',
'disable_application_image_retention',
'connection_timeout',
];
protected $casts = [
@ -108,6 +111,7 @@ class ServerSetting extends Model
'is_usable' => 'boolean',
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
];
protected static function booted()
@ -141,19 +145,54 @@ protected static function booted()
* Validate that a sentinel token contains only safe characters.
* Prevents OS command injection when the token is interpolated into shell commands.
*/
public static function isValidSentinelToken(string $token): bool
public static function isValidSentinelToken(?string $token): bool
{
if ($token === null) {
return false;
}
return (bool) preg_match('/\A[a-zA-Z0-9._\-+=\/]+\z/', $token);
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false)
/**
* Returns a valid sentinel token, regenerating it if the stored value is
* empty, undecryptable, or otherwise invalid. Throws only when regeneration
* still fails to produce a valid token.
*/
public function ensureValidSentinelToken(): string
{
try {
$token = $this->sentinel_token;
} catch (DecryptException) {
$token = null;
}
if (! self::isValidSentinelToken($token)) {
// Clear undecryptable raw value so Eloquent's dirty-check won't try to
// decrypt the bad original during save().
$attrs = $this->getAttributes();
$attrs['sentinel_token'] = null;
$this->setRawAttributes($attrs, true);
$this->generateSentinelToken(save: true, ignoreEvent: true);
$this->refresh();
$token = $this->sentinel_token;
}
if (! self::isValidSentinelToken($token)) {
throw new \RuntimeException('Sentinel token invalid after regeneration. Allowed characters: a-z, A-Z, 0-9, dot, underscore, hyphen, plus, slash, equals.');
}
return $token;
}
public function generateSentinelToken(bool $save = true, bool $ignoreEvent = false): string
{
$data = [
'server_uuid' => $this->server->uuid,
];
$token = json_encode($data);
$encrypted = encrypt($token);
$this->sentinel_token = $encrypted;
$token = encrypt(json_encode($data));
$this->sentinel_token = $token;
if ($save) {
if ($ignoreEvent) {
$this->saveQuietly();

View file

@ -134,8 +134,11 @@ public function databases()
$mongodbs = $this->mongodbs;
$mysqls = $this->mysqls;
$mariadbs = $this->mariadbs;
$keydbs = $this->keydbs;
$dragonflies = $this->dragonflies;
$clickhouses = $this->clickhouses;
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs);
return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses);
}
public function attachedTo()

View file

@ -3,9 +3,12 @@
namespace App\Providers;
use App\Contracts\CustomJobRepositoryInterface;
use App\Exceptions\DeploymentException;
use App\Models\ApplicationDeploymentQueue;
use App\Models\User;
use App\Repositories\CustomJobRepository;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\TimeoutExceededException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\Contracts\JobRepository;
@ -48,6 +51,26 @@ public function boot(): void
]);
}
});
Event::listen(function (JobFailed $event) {
if (! isCloud()) {
return;
}
$exception = $event->exception;
if (! ($exception instanceof DeploymentException) && ! ($exception instanceof TimeoutExceededException)) {
return;
}
try {
$uuid = $event->job->uuid();
if ($uuid) {
app(JobRepository::class)->deleteFailed($uuid);
}
} catch (\Throwable $e) {
// Best-effort scrub; never mask the original failure.
}
});
}
protected function gate(): void

View file

@ -2,7 +2,9 @@
namespace App\Traits;
use App\Models\ServerSetting;
use App\Models\Server;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Log;
trait HasMetrics
{
@ -28,9 +30,15 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
$from = now()->subMinutes($mins)->toIso8601ZuluString();
$endpoint = $this->getMetricsEndpoint($type, $from);
$token = $server->settings->sentinel_token;
if (! ServerSetting::isValidSentinelToken($token)) {
throw new \Exception('Invalid sentinel token format. Please regenerate the token.');
$previousToken = null;
try {
$previousToken = $server->settings->sentinel_token;
} catch (DecryptException) {
// fall through to ensureValidSentinelToken which will regenerate
}
$token = $server->settings->ensureValidSentinelToken();
if ($token !== $previousToken) {
Log::warning('Regenerated sentinel token during metrics read; sentinel container restart required', ['server_id' => $server->id]);
}
$response = instant_remote_process(
@ -61,10 +69,10 @@ private function getMetrics(string $type, int $mins, string $valueField): ?array
private function isServerMetrics(): bool
{
return $this instanceof \App\Models\Server;
return $this instanceof Server;
}
private function getMetricsServer(): \App\Models\Server
private function getMetricsServer(): Server
{
return $this->isServerMetrics() ? $this : $this->destination->server;
}

View file

@ -12,8 +12,9 @@
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, string $commit = 'HEAD', bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null)
{
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$application_id = $application->id;
$deployment_link = Url::fromString($application->link()."/deployment/{$deployment_uuid}");
$deployment_url = $deployment_link->getPath();

View file

@ -0,0 +1,81 @@
<?php
use Illuminate\Support\Facades\Log;
if (! function_exists('auditLog')) {
/**
* Write a security-relevant audit entry to the dedicated `audit` log channel.
*
* Never include secrets (private keys, passwords, tokens, webhook secrets,
* signature header values, env-var values) in $context.
*
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
* @param array<string, mixed> $context Identifiers + outcome details.
* @param string $level Log level: info | warning | error.
*/
function auditLog(string $event, array $context = [], string $level = 'info'): void
{
try {
$request = app()->bound('request') ? request() : null;
$user = auth()->check() ? auth()->user() : null;
$token = $user?->currentAccessToken();
$base = [
'event' => $event,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'user_id' => $user?->id,
'user_email' => $user?->email,
'team_id' => $token ? data_get($token, 'team_id') : null,
'token_id' => $token?->id ?? null,
'token_name' => $token?->name ?? null,
'method' => $request?->method(),
'path' => $request?->path(),
];
$payload = array_merge($base, $context);
Log::channel('audit')->{$level}($event, $payload);
} catch (Throwable $e) {
// Audit logging must never break the request path.
try {
Log::warning('auditLog failed: '.$e->getMessage(), ['event' => $event]);
} catch (Throwable) {
}
}
}
}
if (! function_exists('auditLogWebhookFailure')) {
/**
* Record a webhook signature/auth verification failure to the `audit` channel.
*/
function auditLogWebhookFailure(string $provider, string $reason, array $context = []): void
{
try {
$request = app()->bound('request') ? request() : null;
$event = "webhook.{$provider}.signature_failed";
$base = [
'event' => $event,
'reason' => $reason,
'ip' => $request?->ip(),
'ua' => substr((string) $request?->userAgent(), 0, 200),
'method' => $request?->method(),
'path' => $request?->path(),
'event_header' => $request?->header('X-GitHub-Event')
?? $request?->header('X-Gitlab-Event')
?? $request?->header('X-Gitea-Event')
?? $request?->header('X-Event-Key'),
];
Log::channel('audit')->warning($event, array_merge($base, $context));
} catch (Throwable $e) {
try {
Log::warning('auditLogWebhookFailure failed: '.$e->getMessage(), ['provider' => $provider]);
} catch (Throwable) {
}
}
}
}

View file

@ -1,7 +1,26 @@
<?php
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
const REDACTED = '<REDACTED>';
const DATABASE_TYPES = ['postgresql', 'redis', 'mongodb', 'mysql', 'mariadb', 'keydb', 'dragonfly', 'clickhouse'];
const STANDALONE_DATABASE_MODELS = [
'postgresql' => StandalonePostgresql::class,
'redis' => StandaloneRedis::class,
'mongodb' => StandaloneMongodb::class,
'mysql' => StandaloneMysql::class,
'mariadb' => StandaloneMariadb::class,
'keydb' => StandaloneKeydb::class,
'dragonfly' => StandaloneDragonfly::class,
'clickhouse' => StandaloneClickhouse::class,
];
const VALID_CRON_STRINGS = [
'every_minute' => '* * * * *',
'hourly' => '0 * * * *',

View file

@ -1058,44 +1058,17 @@ function getResourceByUuid(string $uuid, ?int $teamId = null)
}
function queryDatabaseByUuidWithinTeam(string $uuid, string $teamId)
{
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql && $postgresql->team()->id == $teamId) {
return $postgresql->unsetRelation('environment');
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis && $redis->team()->id == $teamId) {
return $redis->unsetRelation('environment');
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb && $mongodb->team()->id == $teamId) {
return $mongodb->unsetRelation('environment');
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql && $mysql->team()->id == $teamId) {
return $mysql->unsetRelation('environment');
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb && $mariadb->team()->id == $teamId) {
return $mariadb->unsetRelation('environment');
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb && $keydb->team()->id == $teamId) {
return $keydb->unsetRelation('environment');
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly && $dragonfly->team()->id == $teamId) {
return $dragonfly->unsetRelation('environment');
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse && $clickhouse->team()->id == $teamId) {
return $clickhouse->unsetRelation('environment');
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database && $database->team()->id == $teamId) {
return $database->unsetRelation('environment');
}
}
return null;
}
function queryResourcesByUuid(string $uuid)
{
$resource = null;
$application = Application::whereUuid($uuid)->first();
if ($application) {
return $application;
@ -1104,37 +1077,11 @@ function queryResourcesByUuid(string $uuid)
if ($service) {
return $service;
}
$postgresql = StandalonePostgresql::whereUuid($uuid)->first();
if ($postgresql) {
return $postgresql;
}
$redis = StandaloneRedis::whereUuid($uuid)->first();
if ($redis) {
return $redis;
}
$mongodb = StandaloneMongodb::whereUuid($uuid)->first();
if ($mongodb) {
return $mongodb;
}
$mysql = StandaloneMysql::whereUuid($uuid)->first();
if ($mysql) {
return $mysql;
}
$mariadb = StandaloneMariadb::whereUuid($uuid)->first();
if ($mariadb) {
return $mariadb;
}
$keydb = StandaloneKeydb::whereUuid($uuid)->first();
if ($keydb) {
return $keydb;
}
$dragonfly = StandaloneDragonfly::whereUuid($uuid)->first();
if ($dragonfly) {
return $dragonfly;
}
$clickhouse = StandaloneClickhouse::whereUuid($uuid)->first();
if ($clickhouse) {
return $clickhouse;
foreach (STANDALONE_DATABASE_MODELS as $modelClass) {
$database = $modelClass::whereUuid($uuid)->first();
if ($database) {
return $database;
}
}
// Check for ServiceDatabase by its own UUID
@ -1143,7 +1090,7 @@ function queryResourcesByUuid(string $uuid)
return $serviceDatabase;
}
return $resource;
return null;
}
function generateTagDeployWebhook($tag_name)
{
@ -1453,23 +1400,23 @@ function generateEnvValue(string $command, Service|Application|null $service = n
break;
// This is base64,
case 'REALBASE64_64':
$generatedValue = base64_encode(Str::random(64));
$generatedValue = base64_encode(random_bytes(64));
break;
case 'REALBASE64_128':
$generatedValue = base64_encode(Str::random(128));
$generatedValue = base64_encode(random_bytes(128));
break;
case 'REALBASE64':
case 'REALBASE64_32':
$generatedValue = base64_encode(Str::random(32));
$generatedValue = base64_encode(random_bytes(32));
break;
case 'HEX_32':
$generatedValue = bin2hex(Str::random(32));
$generatedValue = bin2hex(random_bytes(16));
break;
case 'HEX_64':
$generatedValue = bin2hex(Str::random(64));
$generatedValue = bin2hex(random_bytes(32));
break;
case 'HEX_128':
$generatedValue = bin2hex(Str::random(128));
$generatedValue = bin2hex(random_bytes(64));
break;
case 'USER':
$generatedValue = Str::random(16);

View file

@ -18,6 +18,7 @@
"laravel/fortify": "^1.34.0",
"laravel/framework": "^12.49.0",
"laravel/horizon": "^5.43.0",
"laravel/mcp": "^0.6.7",
"laravel/nightwatch": "^1.24",
"laravel/pail": "^1.2.4",
"laravel/prompts": "^0.3.11|^0.3.11|^0.3.11",

150
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "40bddea995c1744e4aec517263109a2f",
"content-hash": "64b77285a7140ce68e83db2659e9a21d",
"packages": [
{
"name": "aws/aws-crt-php",
@ -2066,6 +2066,79 @@
},
"time": "2026-03-18T14:14:59+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.6.7",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-04-15T08:30:42+00:00"
},
{
"name": "laravel/nightwatch",
"version": "v1.24.4",
@ -13738,79 +13811,6 @@
},
"time": "2026-03-21T11:50:49+00:00"
},
{
"name": "laravel/mcp",
"version": "v0.6.4",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
"reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/mcp/zipball/f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
"reference": "f822c5eb5beed19adb2e5bfe2f46f8c977ecea42",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-mbstring": "*",
"illuminate/console": "^11.45.3|^12.41.1|^13.0",
"illuminate/container": "^11.45.3|^12.41.1|^13.0",
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
"illuminate/http": "^11.45.3|^12.41.1|^13.0",
"illuminate/json-schema": "^12.41.1|^13.0",
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
"php": "^8.2"
},
"require-dev": {
"laravel/pint": "^1.20",
"orchestra/testbench": "^9.15|^10.8|^11.0",
"pestphp/pest": "^3.8.5|^4.3.2",
"phpstan/phpstan": "^2.1.27",
"rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
"laravel",
"mcp"
],
"support": {
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
"time": "2026-03-19T12:37:13+00:00"
},
{
"name": "laravel/pint",
"version": "v1.29.0",
@ -17311,5 +17311,5 @@
"php": "^8.4"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
"plugin-api-version": "2.6.0"
}

View file

@ -1,7 +0,0 @@
{
"scripts": {
"setup": "./scripts/conductor-setup.sh",
"run": "docker rm -f coolify coolify-minio-init coolify-realtime coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite; spin up; spin down"
},
"runScriptMode": "nonconcurrent"
}

View file

@ -2,9 +2,9 @@
return [
'coolify' => [
'version' => '4.0.0-beta.474',
'version' => '4.1.0',
'helper_version' => '1.0.13',
'realtime_version' => '1.0.13',
'realtime_version' => '1.0.14',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),

View file

@ -132,6 +132,14 @@
'level' => 'warning',
'days' => 14,
],
'audit' => [
'driver' => 'daily',
'path' => storage_path('logs/audit.log'),
'level' => env('LOG_AUDIT_LEVEL', 'info'),
'days' => env('LOG_AUDIT_DAYS', 90),
'replace_placeholders' => true,
],
],
];

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->boolean('is_mcp_server_enabled')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('instance_settings', function (Blueprint $table) {
$table->dropColumn('is_mcp_server_enabled');
});
}
};

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->integer('connection_timeout')->default(10)->after('deployment_queue_limit');
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn('connection_timeout');
});
}
};

View file

@ -23,23 +23,25 @@ public function run(): void
'smtp_from_address' => 'hi@localhost.com',
'smtp_from_name' => 'Coolify',
]);
try {
$ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
$ipv4 = trim($ipv4);
$ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
$settings = instanceSettings();
if (is_null($settings->public_ipv4) && $ipv4) {
$settings->update(['public_ipv4' => $ipv4]);
if (! isDev()) {
try {
$ipv4 = Process::run('curl -4s https://ifconfig.io')->output();
$ipv4 = trim($ipv4);
$ipv4 = filter_var($ipv4, FILTER_VALIDATE_IP);
$settings = instanceSettings();
if (is_null($settings->public_ipv4) && $ipv4) {
$settings->update(['public_ipv4' => $ipv4]);
}
$ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
$ipv6 = trim($ipv6);
$ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
$settings = instanceSettings();
if (is_null($settings->public_ipv6) && $ipv6) {
$settings->update(['public_ipv6' => $ipv6]);
}
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
$ipv6 = Process::run('curl -6s https://ifconfig.io')->output();
$ipv6 = trim($ipv6);
$ipv6 = filter_var($ipv6, FILTER_VALIDATE_IP);
$settings = instanceSettings();
if (is_null($settings->public_ipv6) && $ipv6) {
$settings->update(['public_ipv6' => $ipv6]);
}
} catch (\Throwable $e) {
echo "Error: {$e->getMessage()}\n";
}
}
}

View file

@ -129,10 +129,9 @@ services:
networks:
- coolify
minio:
image: ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z # Released on 15 October 2025
image: ghcr.io/coollabsio/maxio:latest
pull_policy: always
container_name: coolify-minio
command: server /data --console-address ":9001"
ports:
- "${FORWARD_MINIO_PORT:-9000}:9000"
- "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001"

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -165,9 +165,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",

View file

@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
const HEARTBEAT_INTERVAL_MS = 30000;
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
wss.on('connection', async (ws, req) => {
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
const userId = generateUserId();
const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
ws.userId = userId;
const userSession = {
ws,
userId,
ptyProcess: null,
isActive: false,
authorizedIPs: [],
lastActivityAt: Date.now(),
authReady: false,
pendingMessages: [],
};
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => {
hasLaravelSession: Boolean(laravelSession),
};
// Register socket handlers up front so messages sent immediately by the client
// (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
// below is still pending.
ws.on('message', (message) => {
if (userSession.authReady) {
handleMessage(userSession, message);
} else {
userSession.pendingMessages.push(message);
}
});
ws.on('error', (err) => handleError(err, userId));
ws.on('close', (code, reason) => {
logTerminal('log', 'Terminal websocket connection closed.', {
userId,
code,
reason: reason?.toString(),
});
handleClose(userId);
});
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => {
}
userSessions.set(userId, userSession);
userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
bufferedMessages: userSession.pendingMessages.length,
});
ws.on('message', (message) => {
handleMessage(userSession, message);
});
ws.on('error', (err) => handleError(err, userId));
ws.on('close', (code, reason) => {
logTerminal('log', 'Terminal websocket connection closed.', {
userId,
code,
reason: reason?.toString(),
});
handleClose(userId);
});
// Drain any messages that arrived while we were waiting on the IP auth call.
while (userSession.pendingMessages.length > 0) {
handleMessage(userSession, userSession.pendingMessages.shift());
}
});
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (ws.isAlive === false) {
logTerminal('warn', 'Terminating WS due to missed protocol pong.');
return ws.terminate();
}
ws.isAlive = false;
try {
ws.ping();
} catch (_) {
// ignore — close handler will follow
}
const session = ws.userId ? userSessions.get(ws.userId) : null;
if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
const idleMs = Date.now() - session.lastActivityAt;
logTerminal('warn', 'Closing terminal session due to idle timeout.', {
userId: ws.userId,
idleMs,
idleTimeoutMs: IDLE_TIMEOUT_MS,
});
try {
ws.send('idle-timeout');
} catch (_) {
// ignore — close still attempted below
}
killPtyProcess(ws.userId);
setTimeout(() => {
try {
ws.close(1000, 'Idle timeout');
} catch (_) {
// ignore — already closed
}
}, 100);
}
});
}, HEARTBEAT_INTERVAL_MS);
wss.on('close', () => clearInterval(heartbeat));
const messageHandlers = {
message: (session, data) => session.ptyProcess.write(data),
message: (session, data) => {
session.lastActivityAt = Date.now();
session.ptyProcess.write(data);
},
resize: (session, { cols, rows }) => {
session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@ -197,12 +271,6 @@ function handleMessage(userSession, message) {
return;
}
logTerminal('log', 'Received websocket message.', {
userId: userSession.userId,
keys: Object.keys(parsed),
isActive: userSession.isActive,
});
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
userSession.lastActivityAt = Date.now();
ws.send('pty-ready');

View file

@ -38,6 +38,8 @@ RUN apk upgrade --no-cache && \
mkdir -p /usr/share/keyrings && \
curl -fSsL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor > /usr/share/keyrings/postgresql.gpg
RUN sed -i 's|http://nginx.org/packages|https://nginx.org/packages|g' /etc/apk/repositories
# Install system dependencies
RUN apk add --no-cache \
postgresql${POSTGRES_VERSION}-client \

View file

@ -4381,8 +4381,8 @@
"description": "Number of days to retain backups locally"
},
"database_backup_retention_max_storage_locally": {
"type": "integer",
"description": "Max storage (MB) for local backups"
"type": "number",
"description": "Max storage (GB) for local backups"
},
"database_backup_retention_amount_s3": {
"type": "integer",
@ -4393,8 +4393,8 @@
"description": "Number of days to retain backups in S3"
},
"database_backup_retention_max_storage_s3": {
"type": "integer",
"description": "Max storage (MB) for S3 backups"
"type": "number",
"description": "Max storage (GB) for S3 backups"
},
"timeout": {
"type": "integer",
@ -4951,7 +4951,7 @@
"description": "Retention days of the backup locally"
},
"database_backup_retention_max_storage_locally": {
"type": "integer",
"type": "number",
"description": "Max storage of the backup locally"
},
"database_backup_retention_amount_s3": {
@ -4963,7 +4963,7 @@
"description": "Retention days of the backup in s3"
},
"database_backup_retention_max_storage_s3": {
"type": "integer",
"type": "number",
"description": "Max storage of the backup in S3"
},
"timeout": {
@ -8650,6 +8650,110 @@
]
}
},
"\/mcp\/enable": {
"post": {
"summary": "Enable MCP Server",
"description": "Enable the MCP server endpoint at \/mcp (only with root permissions).",
"operationId": "enable-mcp",
"responses": {
"200": {
"description": "MCP server enabled.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "MCP server enabled."
}
},
"type": "object"
}
}
}
},
"403": {
"description": "You are not allowed to enable the MCP server.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "You are not allowed to enable the MCP server."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/mcp\/disable": {
"post": {
"summary": "Disable MCP Server",
"description": "Disable the MCP server endpoint at \/mcp (only with root permissions).",
"operationId": "disable-mcp",
"responses": {
"200": {
"description": "MCP server disabled.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "MCP server disabled."
}
},
"type": "object"
}
}
}
},
"403": {
"description": "You are not allowed to disable the MCP server.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "You are not allowed to disable the MCP server."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/health": {
"get": {
"summary": "Healthcheck",
@ -10545,6 +10649,10 @@
"server_disk_usage_check_frequency": {
"type": "string",
"description": "Cron expression for disk usage check frequency."
},
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds (1-300). Default: 10."
}
},
"type": "object"
@ -13349,6 +13457,10 @@
"delete_unused_networks": {
"type": "boolean",
"description": "The flag to indicate if the unused networks should be deleted."
},
"connection_timeout": {
"type": "integer",
"description": "SSH connection timeout in seconds."
}
},
"type": "object"

View file

@ -2765,8 +2765,8 @@ paths:
type: integer
description: 'Number of days to retain backups locally'
database_backup_retention_max_storage_locally:
type: integer
description: 'Max storage (MB) for local backups'
type: number
description: 'Max storage (GB) for local backups'
database_backup_retention_amount_s3:
type: integer
description: 'Number of backups to retain in S3'
@ -2774,8 +2774,8 @@ paths:
type: integer
description: 'Number of days to retain backups in S3'
database_backup_retention_max_storage_s3:
type: integer
description: 'Max storage (MB) for S3 backups'
type: number
description: 'Max storage (GB) for S3 backups'
timeout:
type: integer
description: 'Backup job timeout in seconds (min: 60, max: 36000)'
@ -3160,7 +3160,7 @@ paths:
type: integer
description: 'Retention days of the backup locally'
database_backup_retention_max_storage_locally:
type: integer
type: number
description: 'Max storage of the backup locally'
database_backup_retention_amount_s3:
type: integer
@ -3169,7 +3169,7 @@ paths:
type: integer
description: 'Retention days of the backup in s3'
database_backup_retention_max_storage_s3:
type: integer
type: number
description: 'Max storage of the backup in S3'
timeout:
type: integer
@ -5484,6 +5484,64 @@ paths:
security:
-
bearerAuth: []
/mcp/enable:
post:
summary: 'Enable MCP Server'
description: 'Enable the MCP server endpoint at /mcp (only with root permissions).'
operationId: enable-mcp
responses:
'200':
description: 'MCP server enabled.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'MCP server enabled.' }
type: object
'403':
description: 'You are not allowed to enable the MCP server.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'You are not allowed to enable the MCP server.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
/mcp/disable:
post:
summary: 'Disable MCP Server'
description: 'Disable the MCP server endpoint at /mcp (only with root permissions).'
operationId: disable-mcp
responses:
'200':
description: 'MCP server disabled.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'MCP server disabled.' }
type: object
'403':
description: 'You are not allowed to disable the MCP server.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'You are not allowed to disable the MCP server.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
security:
-
bearerAuth: []
/health:
get:
summary: Healthcheck
@ -6734,6 +6792,9 @@ paths:
server_disk_usage_check_frequency:
type: string
description: 'Cron expression for disk usage check frequency.'
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds (1-300). Default: 10.'
type: object
responses:
'201':
@ -8538,6 +8599,9 @@ components:
delete_unused_networks:
type: boolean
description: 'The flag to indicate if the unused networks should be deleted.'
connection_timeout:
type: integer
description: 'SSH connection timeout in seconds.'
type: object
Service:
description: 'Service model'

View file

@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"

View file

@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always

View file

@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
"version": "4.0.0-beta.474"
"version": "4.1.0"
},
"nightly": {
"version": "4.0.0"
@ -10,7 +10,7 @@
"version": "1.0.13"
},
"realtime": {
"version": "1.0.13"
"version": "1.0.14"
},
"sentinel": {
"version": "0.0.21"

BIN
public/svgs/cap-captcha.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -343,3 +343,12 @@ @utility log-debug {
@utility log-info {
@apply bg-blue-500/10 dark:bg-blue-500/15;
}
@media (min-width: 1024px) {
.sidebar-collapsed .menu-item {
justify-content: center;
padding-left: 0;
padding-right: 0;
gap: 0;
}
}

View file

@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
// Last successfully sent SSH command — replayed after a transient reconnect
// so the PTY respawns automatically. Cleared on intentional terminations
// (pty-exited, idle-timeout, unprocessable).
lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
[this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearInterval(timer));
if (this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
}
[this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
.forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
resetTerminal() {
if (this.term) {
this.$wire.dispatch('error', 'Terminal websocket connection lost.');
this.term.reset();
this.term.clear();
this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
// Preserve scrollback so the user keeps the context of their previous
// session. Print a visible marker so they know where the disconnect
// happened. Old PTY shell state cannot be restored — this is purely
// a visual carry-over.
try {
const stamp = new Date().toLocaleTimeString();
this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
} catch (_) {
// ignore — terminal not ready to receive writes
}
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
// Flush any buffered command from before WebSocket was ready
// Flush any buffered command from before WebSocket was ready, otherwise
// replay the last command so a transient reconnect respawns the PTY
// automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
} else if (this.lastSentCommand) {
logTerminal('log', '[Terminal] Replaying last command after reconnect.');
this.sendMessage(this.lastSentCommand);
}
// (Re)start application-level keepalive on every successful connect.
// Server-side WebSocket protocol pings are the primary heartbeat; this
// adds a JSON-level ping in case the server-side is older or restarting.
if (!this.keepAliveInterval) {
this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
if (message && message.command) {
this.lastSentCommand = message;
}
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
},
handleSocketMessage(event) {
logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
this.term.reset();
// Already initialized — this is a reconnect or a follow-up command.
// Preserve scrollback so the user keeps context. Write a visible
// separator so the new shell prompt is easy to spot.
try {
const stamp = new Date().toLocaleTimeString();
this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
} catch (_) {
// ignore — fall through; xterm will render the new prompt anyway
}
}
this.terminalActive = true;
this.term.focus();
@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
this.lastSentCommand = null;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
} else if (event.data === 'idle-timeout') {
this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
this.terminalActive = false;
if (this.term) {
this.term.reset();
}
this.commandBuffer = '';
this.lastSentCommand = null;
this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
// Skip keepalive when document is hidden to prevent unnecessary disconnects
if (!this.isDocumentVisible) {
return;
}
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
// Send immediate ping to verify connection is still alive
// Connection may be half-open after Cloudflare/proxy idle drop while hidden.
// Probe with a short timeout (5s) instead of the default 35s — force a
// reconnect quickly if no pong arrives so the user is not stuck typing
// into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
this.resetPingTimeout();
if (this.pingTimeoutId) {
clearTimeout(this.pingTimeoutId);
}
this.pingTimeoutId = setTimeout(() => {
logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
try {
this.socket.close(4000, 'Visibility-resume timeout');
} catch (_) {
// ignore — close handler will run on its own
}
}, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;

View file

@ -229,7 +229,7 @@ class="flex absolute inset-y-0 right-0 z-10 items-center pr-2 cursor-pointer dar
@readonly($readonly)
@if ($modelBinding !== 'null')
wire:model="{{ $modelBinding }}"
wire:dirty.class="dark:border-l-warning border-l-coollabs border-l-4"
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
@endif
wire:loading.attr="disabled"
@disabled($disabled)

View file

@ -1,5 +1,20 @@
<nav class="flex flex-col flex-1 px-2 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
<nav class="flex flex-col flex-1 bg-white border-r dark:border-coolgray-200 border-neutral-300 dark:bg-base"
:class="collapsed ? 'lg:px-1 px-2 sidebar-collapsed' : 'px-2'"
@mouseover="
if (!collapsed) return;
const el = $event.target.closest('.menu-item');
if (!el) { tooltip.show = false; return; }
const text = el.getAttribute('title') || el.getAttribute('aria-label') || '';
if (!text) return;
const rect = el.getBoundingClientRect();
tooltip.text = text;
tooltip.x = rect.right + 8;
tooltip.y = rect.top + rect.height / 2;
tooltip.show = true;
"
@mouseleave="tooltip.show = false"
x-data="{
tooltip: { text: '', x: 0, y: 0, show: false },
switchWidth() {
if (this.full === 'full') {
localStorage.setItem('pageWidth', 'center');
@ -77,12 +92,22 @@
}
}
}">
<div class="flex lg:pt-6 pt-4 pb-4 pl-2">
<div class="flex flex-col w-full">
<div class="flex pt-4 pb-4 pl-2 items-start gap-2"
:class="collapsed ? 'lg:flex-col lg:items-center lg:pl-0 lg:gap-3 lg:pt-8' : 'lg:pt-6'">
<div class="flex flex-col w-full" :class="collapsed && 'lg:hidden'">
<a href="/" {{ wireNavigate() }} class="text-2xl font-bold tracking-tight dark:text-white hover:opacity-80 transition-opacity">Coolify</a>
<x-version />
</div>
<div>
<div class="hidden flex-col items-center w-full gap-1"
:class="collapsed && 'lg:flex'">
<a href="/" {{ wireNavigate() }}
class="hover:opacity-80 transition-opacity"
title="Coolify">
<img src="/coolify-logo.svg" alt="Coolify" class="w-6 h-6" />
</a>
<x-version class="text-[10px]" />
</div>
<div :class="collapsed && 'lg:hidden'">
<!-- Search button that triggers global search modal -->
<button @click="$dispatch('open-global-search')" type="button" title="Search (Press / or ⌘K)"
class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md hover:bg-neutral-200 dark:hover:bg-coolgray-200 transition-colors">
@ -95,9 +120,11 @@ class="flex items-center gap-1.5 px-2.5 py-1.5 bg-neutral-100 dark:bg-coolgray-1
class="px-1 py-0.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 bg-neutral-200 dark:bg-coolgray-200 rounded">/</kbd>
</button>
</div>
<livewire:settings-dropdown />
<div :class="collapsed && 'lg:hidden'">
<livewire:settings-dropdown />
</div>
</div>
<div class="px-2 pt-2 pb-7">
<div class="px-2 pt-2 pb-7" :class="collapsed && 'lg:px-0 lg:pt-0 lg:pb-4 lg:flex lg:justify-center'">
<livewire:switch-team />
</div>
<ul role="list" class="flex flex-col flex-1 gap-y-7">
@ -112,7 +139,7 @@ class="{{ request()->is('/') ? 'menu-item-active menu-item' : 'menu-item' }}">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span class="menu-item-label">Dashboard</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Dashboard</span>
</a>
</li>
<li>
@ -127,7 +154,7 @@ class="{{ request()->is('project/*') || request()->is('projects') ? 'menu-item m
<path d="M4 12l8 4l8 -4" />
<path d="M4 16l8 4l8 -4" />
</svg>
<span class="menu-item-label">Projects</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Projects</span>
</a>
</li>
<li>
@ -145,7 +172,7 @@ class="{{ request()->is('server/*') || request()->is('servers') ? 'menu-item men
<path d="M7 16v.01" />
<path d="M20 15l-2 3h3l-2 3" />
</svg>
<span class="menu-item-label">Servers</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Servers</span>
</a>
</li>
@ -157,7 +184,7 @@ class="{{ request()->is('source*') ? 'menu-item-active menu-item' : 'menu-item'
<path fill="currentColor"
d="m6.793 1.207l.353.354l-.353-.354ZM1.207 6.793l-.353-.354l.353.354Zm0 1.414l.354-.353l-.354.353Zm5.586 5.586l-.354.353l.354-.353Zm1.414 0l-.353-.354l.353.354Zm5.586-5.586l.353.354l-.353-.354Zm0-1.414l-.354.353l.354-.353ZM8.207 1.207l.354-.353l-.354.353ZM6.44.854L.854 6.439l.707.707l5.585-5.585L6.44.854ZM.854 8.56l5.585 5.585l.707-.707l-5.585-5.585l-.707.707Zm7.707 5.585l5.585-5.585l-.707-.707l-5.585 5.585l.707.707Zm5.585-7.707L8.561.854l-.707.707l5.585 5.585l.707-.707Zm0 2.122a1.5 1.5 0 0 0 0-2.122l-.707.707a.5.5 0 0 1 0 .708l.707.707ZM6.44 14.146a1.5 1.5 0 0 0 2.122 0l-.707-.707a.5.5 0 0 1-.708 0l-.707.707ZM.854 6.44a1.5 1.5 0 0 0 0 2.122l.707-.707a.5.5 0 0 1 0-.708L.854 6.44Zm6.292-4.878a.5.5 0 0 1 .708 0L8.56.854a1.5 1.5 0 0 0-2.122 0l.707.707Zm-2 1.293l1 1l.708-.708l-1-1l-.708.708ZM7.5 5a.5.5 0 0 1-.5-.5H6A1.5 1.5 0 0 0 7.5 6V5Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 9 4.5H8ZM7.5 4a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 7.5 3v1Zm0-1A1.5 1.5 0 0 0 6 4.5h1a.5.5 0 0 1 .5-.5V3Zm.646 2.854l1.5 1.5l.707-.708l-1.5-1.5l-.707.708ZM10.5 8a.5.5 0 0 1-.5-.5H9A1.5 1.5 0 0 0 10.5 9V8Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 12 7.5h-1Zm-.5-.5a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 10.5 6v1Zm0-1A1.5 1.5 0 0 0 9 7.5h1a.5.5 0 0 1 .5-.5V6ZM7 5.5v4h1v-4H7Zm.5 5.5a.5.5 0 0 1-.5-.5H6A1.5 1.5 0 0 0 7.5 12v-1Zm.5-.5a.5.5 0 0 1-.5.5v1A1.5 1.5 0 0 0 9 10.5H8Zm-.5-.5a.5.5 0 0 1 .5.5h1A1.5 1.5 0 0 0 7.5 9v1Zm0-1A1.5 1.5 0 0 0 6 10.5h1a.5.5 0 0 1 .5-.5V9Z" />
</svg>
<span class="menu-item-label">Sources</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Sources</span>
</a>
</li>
<li>
@ -170,7 +197,7 @@ class="{{ request()->is('destination*') ? 'menu-item-active menu-item' : 'menu-i
stroke-linejoin="round" stroke-width="2"
d="M9 4L3 8v12l6-3l6 3l6-4V4l-6 3l-6-3zm-2 8.001V12m4 .001V12m3-2l2 2m2 2l-2-2m0 0l2-2m-2 2l-2 2" />
</svg>
<span class="menu-item-label">Destinations</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Destinations</span>
</a>
</li>
<li>
@ -185,7 +212,7 @@ class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item
<path d="M4 12v6a8 3 0 0 0 16 0v-6" />
</g>
</svg>
<span class="menu-item-label">S3 Storages</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">S3 Storages</span>
</a>
</li>
<li>
@ -200,7 +227,7 @@ class="{{ request()->is('shared-variables*') ? 'menu-item-active menu-item' : 'm
<path d="M8 16c1.5 0 3-2 4-3.5S14.5 9 16 9" />
</g>
</svg>
<span class="menu-item-label">Shared Variables</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Shared Variables</span>
</a>
</li>
<li>
@ -212,7 +239,7 @@ class="{{ request()->is('notifications*') ? 'menu-item-active menu-item' : 'menu
stroke-linejoin="round" stroke-width="2"
d="M10 5a2 2 0 1 1 4 0a7 7 0 0 1 4 6v3a4 4 0 0 0 2 3H4a4 4 0 0 0 2-3v-3a7 7 0 0 1 4-6M9 17v1a3 3 0 0 0 6 0v-1" />
</svg>
<span class="menu-item-label">Notifications</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Notifications</span>
</a>
</li>
<li>
@ -224,7 +251,7 @@ class="{{ request()->is('security*') ? 'menu-item-active menu-item' : 'menu-item
stroke-linejoin="round" stroke-width="2"
d="m16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1-4.069 0l-.301-.301l-6.558 6.558a2 2 0 0 1-1.239.578L5.172 21H4a1 1 0 0 1-.993-.883L3 20v-1.172a2 2 0 0 1 .467-1.284l.119-.13L4 17h2v-2h2v-2l2.144-2.144l-.301-.301a2.877 2.877 0 0 1 0-4.069l2.643-2.643a2.877 2.877 0 0 1 4.069 0zM15 9h.01" />
</svg>
<span class="menu-item-label">Keys & Tokens</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Keys & Tokens</span>
</a>
</li>
<li>
@ -239,7 +266,7 @@ class="{{ request()->is('tags*') ? 'menu-item-active menu-item' : 'menu-item' }}
<path d="m18 19l1.592-1.592a4.82 4.82 0 0 0 0-6.816L15 6m-8 4h-.01" />
</g>
</svg>
<span class="menu-item-label">Tags</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Tags</span>
</a>
</li>
@can('canAccessTerminal')
@ -254,7 +281,7 @@ class="{{ request()->is('terminal*') ? 'menu-item-active menu-item' : 'menu-item
<path d="M5 7l5 5l-5 5" />
<path d="M12 19l7 0" />
</svg>
<span class="menu-item-label">Terminal</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Terminal</span>
</a>
</li>
@endcan
@ -270,7 +297,7 @@ class="{{ request()->is('profile*') ? 'menu-item-active menu-item' : 'menu-item'
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
</svg>
<span class="menu-item-label">Profile</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Profile</span>
</a>
</li>
<li>
@ -288,7 +315,7 @@ class="{{ request()->is('team*') ? 'menu-item-active menu-item' : 'menu-item' }}
<path d="M5 5a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M3 13v-1a2 2 0 0 1 2 -2h2" />
</svg>
<span class="menu-item-label">Teams</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Teams</span>
</a>
</li>
@if (isCloud() && auth()->user()->isAdmin())
@ -301,7 +328,7 @@ class="{{ request()->is('subscription*') ? 'menu-item-active menu-item' : 'menu-
stroke-linejoin="round" stroke-width="2"
d="M3 8a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3zm0 2h18M7 15h.01M11 15h2" />
</svg>
<span class="menu-item-label">Subscription</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Subscription</span>
</a>
</li>
@endif
@ -319,7 +346,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z" />
<path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>
<span class="menu-item-label">Settings</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Settings</span>
</a>
</li>
@endif
@ -333,7 +360,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
<path fill="currentColor"
d="M177.62 159.6a52 52 0 0 1-34 34a12.2 12.2 0 0 1-3.6.55a12 12 0 0 1-3.6-23.45a28 28 0 0 0 18.32-18.32a12 12 0 0 1 22.9 7.2ZM220 144a92 92 0 0 1-184 0c0-28.81 11.27-58.18 33.48-87.28a12 12 0 0 1 17.9-1.33l19.69 19.11L127 19.89a12 12 0 0 1 18.94-5.12C168.2 33.25 220 82.85 220 144m-24 0c0-41.71-30.61-78.39-52.52-99.29l-20.21 55.4a12 12 0 0 1-19.63 4.5L80.71 82.36C67 103.38 60 124.06 60 144a68 68 0 0 0 136 0" />
</svg>
<span class="menu-item-label">Admin</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Admin</span>
</a>
</li>
@endif
@ -341,7 +368,7 @@ class="{{ request()->is('settings*') ? 'menu-item-active menu-item' : 'menu-item
<div class="flex-1"></div>
@if (isInstanceAdmin() && !isCloud())
@persist('upgrade')
<li>
<li :class="collapsed && 'lg:hidden'">
<livewire:upgrade />
</li>
@endpersist
@ -368,7 +395,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
d="M12 6L8.707 9.293a1 1 0 0 0 0 1.414l.543.543c.69.69 1.81.69 2.5 0l1-1a3.182 3.182 0 0 1 4.5 0l2.25 2.25m-7 3l2 2M15 13l2 2" />
</g>
</svg>
<span class="menu-item-label">Sponsor us</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Sponsor us</span>
</a>
</li>
@endif
@ -384,7 +411,7 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
<path fill="currentColor"
d="M140 180a12 12 0 1 1-12-12a12 12 0 0 1 12 12M128 72c-22.06 0-40 16.15-40 36v4a8 8 0 0 0 16 0v-4c0-11 10.77-20 24-20s24 9 24 20s-10.77 20-24 20a8 8 0 0 0-8 8v8a8 8 0 0 0 16 0v-.72c18.24-3.35 32-17.9 32-35.28c0-19.85-17.94-36-40-36m104 56A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</svg>
<span class="menu-item-label">Feedback</span>
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Feedback</span>
</div>
</x-slot:content>
<livewire:help />
@ -394,15 +421,21 @@ class="{{ request()->is('onboarding*') ? 'menu-item-active menu-item' : 'menu-it
<form action="/logout" method="POST">
@csrf
<button title="Logout" type="submit" class="gap-2 mb-6 menu-item">
<svg class="menu-item-icon mr-1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg class="menu-item-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor"
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2a9.985 9.985 0 0 1 8 4h-2.71a8 8 0 1 0 .001 12h2.71A9.985 9.985 0 0 1 12 22m7-6v-3h-8v-2h8V8l5 4z" />
</svg>
<span>Logout</span>
<span :class="collapsed && 'lg:hidden'">Logout</span>
</button>
</form>
</li>
</ul>
</li>
</ul>
<div x-show="collapsed && tooltip.show"
x-cloak
x-transition.opacity.duration.100ms
:style="`left: ${tooltip.x}px; top: ${tooltip.y}px;`"
class="fixed z-[100] -translate-y-1/2 px-2 py-1 text-xs font-medium rounded-md bg-neutral-900 dark:bg-coolgray-300 text-white whitespace-nowrap pointer-events-none shadow-lg border border-neutral-700 dark:border-coolgray-200"
x-text="tooltip.text"></div>
</nav>

View file

@ -10,12 +10,19 @@
<livewire:deployments-indicator />
<div x-data="{
open: false,
collapsed: false,
pageWidth: 'full',
init() {
this.pageWidth = localStorage.getItem('pageWidth');
if (!this.pageWidth) {
this.pageWidth = 'full';
localStorage.setItem('pageWidth', 'full');
}
this.collapsed = localStorage.getItem('sidebarCollapsed') === 'true';
},
toggleSidebar() {
this.collapsed = !this.collapsed;
localStorage.setItem('sidebarCollapsed', this.collapsed);
}
}" x-cloak class="mx-auto dark:text-inherit text-black"
:class="pageWidth === 'full' ? '' : 'max-w-7xl'">
@ -40,10 +47,20 @@
</div>
</div>
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-56 lg:flex-col min-w-0">
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col min-w-0 transition-[width] duration-200"
:class="collapsed ? 'lg:w-16' : 'lg:w-56'">
<div class="flex flex-col overflow-y-auto grow gap-y-5 scrollbar min-w-0">
<x-navbar />
</div>
<button type="button" @click="toggleSidebar()"
:title="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
class="absolute top-8 -right-3 z-50 hidden lg:flex items-center justify-center w-6 h-6 rounded-full border bg-white dark:bg-coolgray-100 dark:border-coolgray-200 border-neutral-300 hover:bg-neutral-100 dark:hover:bg-coolgray-200 transition-colors shadow-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5 text-neutral-600 dark:text-neutral-300 transition-transform"
:class="collapsed ? '' : 'rotate-180'"
fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
<div
@ -62,7 +79,7 @@ class="text-xl font-bold tracking-wide dark:text-white hover:opacity-80 transiti
</button>
</div>
<main class="lg:pl-56">
<main class="transition-[padding] duration-200" :class="collapsed ? 'lg:pl-16' : 'lg:pl-56'">
<div class="p-4 sm:px-6 lg:px-8 lg:py-6">
{{ $slot }}
</div>

View file

@ -9,6 +9,9 @@
fullscreen: @entangle('fullscreen'),
alwaysScroll: {{ $isKeepAliveOn ? 'true' : 'false' }},
rafId: null,
scrollDebounce: null,
isScrolling: false,
lastTouchY: 0,
showTimestamps: true,
searchQuery: '',
matchCount: 0,
@ -19,9 +22,54 @@
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
this.isScrolling = true;
logsContainer.scrollTop = logsContainer.scrollHeight;
setTimeout(() => { this.isScrolling = false; }, 50);
}
},
disableFollow() {
if (!this.alwaysScroll) return;
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
},
handleWheel(event) {
if (this.alwaysScroll && event.deltaY < 0) {
this.disableFollow();
}
},
handleTouchStart(event) {
this.lastTouchY = event.touches[0].clientY;
},
handleTouchMove(event) {
if (!this.alwaysScroll) return;
const currentY = event.touches[0].clientY;
if (currentY > this.lastTouchY) {
this.disableFollow();
}
this.lastTouchY = currentY;
},
handleKeyScroll(event) {
if (!this.alwaysScroll) return;
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
if (upKeys.includes(event.key)) {
this.disableFollow();
}
},
handleScroll(event) {
if (this.isScrolling) return;
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (!this.alwaysScroll && distanceFromBottom <= 10) {
this.alwaysScroll = true;
this.scheduleScroll();
}
}, 150);
},
scheduleScroll() {
if (!this.alwaysScroll) return;
this.rafId = requestAnimationFrame(() => {
@ -107,9 +155,9 @@
this.matchCount = query ? count : 0;
},
downloadLogs() {
collectVisibleLogs() {
const logs = document.getElementById('logs');
if (!logs) return;
if (!logs) return '';
const visibleLines = logs.querySelectorAll('[data-log-line]:not(.hidden)');
let content = '';
visibleLines.forEach(line => {
@ -118,6 +166,17 @@
content += text + String.fromCharCode(10);
}
});
return content;
},
copyLogs() {
const content = this.collectVisibleLogs();
if (!content) return;
navigator.clipboard.writeText(content);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
},
downloadLogs() {
const content = this.collectVisibleLogs();
if (!content) return;
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@ -210,12 +269,7 @@ class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-6
</div>
<div class="flex flex-wrap items-center gap-1">
<button
x-on:click="
$wire.copyLogs().then(logs => {
navigator.clipboard.writeText(logs);
Livewire.dispatch('success', ['Logs copied to clipboard.']);
});
"
x-on:click="copyLogs()"
title="Copy Logs"
class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
@ -327,7 +381,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
</div>
</div>
</div>
<div id="logsContainer"
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
class="flex min-h-40 flex-1 flex-col overflow-y-auto p-2 px-4 scrollbar">
<div id="logs" class="flex flex-col font-logs">
<div x-show="searchQuery.trim() && matchCount === 0"

View file

@ -106,7 +106,7 @@
min="0"
helper="Automatically removes backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageLocally"
type="number" min="0"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.001 for 1MB). Set to 0 for unlimited storage." required />
</div>
</div>
@ -122,7 +122,7 @@
min="0"
helper="Automatically removes S3 backups older than the specified number of days. Set to 0 for no time limit." required />
<x-forms.input label="Maximum storage (GB)" id="databaseBackupRetentionMaxStorageS3"
type="number" min="0"
type="number" min="0" step="any"
helper="When total size of all backups in the current backup job exceeds this limit in GB, the oldest backups will be removed. Decimal values are supported (e.g. 0.5 for 500MB). Set to 0 for unlimited storage." required />
</div>
</div>

View file

@ -1,6 +1,10 @@
<div>
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
@if ($fileStorage->is_too_large)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
File on server exceeds 5 MB and cannot be edited from the UI. Edit it directly on the server.
</div>
@elseif ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory)
This directory is mounted as read-only and cannot be modified from the UI.
@ -44,7 +48,7 @@
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
@if (!$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
@ -76,8 +80,8 @@
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
helper="The content shown may be outdated. Click 'Load from server' to fetch the latest version."
rows="20" id="content"
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary || $fileStorage->is_too_large }}"></x-forms.textarea>
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary && !$fileStorage->is_too_large)
<x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif
@else

View file

@ -28,6 +28,38 @@
}
},
isScrolling: false,
lastTouchY: 0,
disableFollow() {
if (!this.alwaysScroll) return;
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
},
handleWheel(event) {
if (this.alwaysScroll && event.deltaY < 0) {
this.disableFollow();
}
},
handleTouchStart(event) {
this.lastTouchY = event.touches[0].clientY;
},
handleTouchMove(event) {
if (!this.alwaysScroll) return;
const currentY = event.touches[0].clientY;
if (currentY > this.lastTouchY) {
this.disableFollow();
}
this.lastTouchY = currentY;
},
handleKeyScroll(event) {
if (!this.alwaysScroll) return;
const upKeys = ['ArrowUp', 'PageUp', 'Home'];
if (upKeys.includes(event.key)) {
this.disableFollow();
}
},
scrollToBottom() {
const logsContainer = document.getElementById('logsContainer');
if (logsContainer) {
@ -57,17 +89,14 @@
}
},
handleScroll(event) {
if (!this.alwaysScroll || this.isScrolling) return;
if (this.isScrolling) return;
clearTimeout(this.scrollDebounce);
this.scrollDebounce = setTimeout(() => {
const el = event.target;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 100) {
this.alwaysScroll = false;
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (!this.alwaysScroll && distanceFromBottom <= 10) {
this.alwaysScroll = true;
this.scheduleScroll();
}
}, 150);
},
@ -473,7 +502,8 @@ class="p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-
</div>
</div>
</div>
<div id="logsContainer" @scroll="handleScroll"
<div id="logsContainer" @scroll="handleScroll" @wheel="handleWheel"
@touchstart="handleTouchStart" @touchmove="handleTouchMove" @keydown="handleKeyScroll" tabindex="0"
class="flex overflow-y-auto overflow-x-hidden flex-col px-4 py-2 w-full min-w-0 scrollbar"
:class="fullscreen ? 'flex-1' : 'max-h-[40rem]'">
@if ($outputs)

View file

@ -191,6 +191,12 @@ class="mt-8 mb-4 w-full font-bold box-without-bg bg-coollabs hover:bg-coollabs-1
label="Port" required :disabled="$isValidating" />
</div>
</div>
<div class="w-full lg:w-64">
<x-forms.input canGate="update" :canResource="$server" type="number"
id="connectionTimeout" label="SSH Connection Timeout (s)"
helper="Seconds to wait for SSH connection before failing. Default: 10."
min="1" max="300" required :disabled="$isValidating" />
</div>
<div class="w-full">
<div class="flex items-center mb-1">
<label for="serverTimezone">Server Timezone</label>

View file

@ -70,6 +70,17 @@ class="flex flex-col h-full gap-8 sm:flex-row">
environments!
</x-callout>
@endif
<h4 class="pt-4">MCP Server</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_mcp_server_enabled" label="Enable MCP Server"
helper="Exposes a Streamable HTTP Model Context Protocol endpoint at /mcp for AI clients (Claude Desktop, Cursor, etc.). Authenticates via Sanctum API tokens (Security > API Tokens). Requires API Access to be enabled." />
</div>
@if ($is_mcp_server_enabled)
<x-callout type="info" title="MCP Endpoint" class="mt-2">
Endpoint: <code>{{ url('/mcp') }}</code><br>
Authenticate with <code>Authorization: Bearer &lt;token&gt;</code> using a token created in Security &raquo; API Tokens.
</x-callout>
@endif
<h4 class="pt-4">UI Settings</h4>
<div class="md:w-96">
<x-forms.checkbox instantSave id="is_wire_navigate_enabled" label="SPA Navigation"

View file

@ -249,23 +249,23 @@ class="w-20 px-2 py-1 text-xl font-bold text-center rounded border dark:bg-coolg
{{-- Refund --}}
<section>
<h3 class="pb-2">Refund</h3>
<div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading)
<x-forms.button disabled>Request Full Refund</x-forms.button>
@elseif ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end)
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
isErrorButton submitAction="refundSubscription"
:actions="[
'Your latest payment will be fully refunded.',
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
step2ButtonText="Confirm Refund & Cancel" />
@else
<x-forms.button disabled>Request Full Refund</x-forms.button>
@endif
</div>
@if ($refundCheckLoading || ($isRefundEligible && !currentTeam()->subscription->stripe_cancel_at_period_end))
<div class="flex flex-wrap items-center gap-2">
@if ($refundCheckLoading)
<x-forms.button disabled>Request Full Refund</x-forms.button>
@else
<x-modal-confirmation title="Request Full Refund?" buttonTitle="Request Full Refund"
isErrorButton submitAction="refundSubscription"
:actions="[
'Your latest payment will be fully refunded.',
'Your subscription will be cancelled immediately.',
'All servers will be deactivated.',
]" confirmationText="{{ currentTeam()->name }}"
confirmationLabel="Enter your team name to confirm" shortConfirmationLabel="Team Name"
step2ButtonText="Confirm Refund & Cancel" />
@endif
</div>
@endif
<p class="mt-2 text-sm text-neutral-500">
@if ($refundCheckLoading)
Checking refund eligibility...

View file

@ -1,6 +1,49 @@
<x-forms.select wire:model.live="selectedTeamId">
<option value="default" disabled selected>Switch team</option>
@foreach (auth()->user()->teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</x-forms.select>
@php
$currentTeam = auth()->user()->currentTeam();
$teamInitial = strtoupper(mb_substr($currentTeam->name, 0, 1));
@endphp
<div>
<div :class="collapsed && 'lg:hidden'">
<x-forms.select wire:model.live="selectedTeamId">
<option value="default" disabled selected>Switch team</option>
@foreach (auth()->user()->teams as $team)
<option value="{{ $team->id }}">{{ $team->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="hidden"
:class="collapsed && 'lg:block'"
x-data="{
teamOpen: false,
teamX: 0,
teamY: 0,
openTeamMenu(ev) {
const rect = ev.currentTarget.getBoundingClientRect();
this.teamX = rect.right + 8;
this.teamY = rect.top;
this.teamOpen = !this.teamOpen;
}
}">
<button @click="openTeamMenu($event)" type="button"
title="Team: {{ $currentTeam->name }}"
class="flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-md bg-coollabs hover:opacity-80 transition-opacity text-white cursor-pointer">
{{ $teamInitial }}
</button>
<div x-show="teamOpen"
@click.outside="teamOpen = false"
x-transition.opacity.duration.100ms
x-cloak
:style="`left: ${teamX}px; top: ${teamY}px;`"
class="fixed z-[100] min-w-48 max-h-72 overflow-y-auto bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-200 rounded-md shadow-lg py-1">
<div class="px-3 py-1.5 text-xs font-semibold text-neutral-500 dark:text-neutral-400 border-b border-neutral-200 dark:border-coolgray-200">Switch team</div>
@foreach (auth()->user()->teams as $team)
<button type="button"
wire:click="switch_to({{ $team->id }})"
@click="teamOpen = false"
class="w-full px-3 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-coolgray-200 dark:text-white {{ $team->id === $currentTeam->id ? 'font-semibold text-coollabs dark:text-warning' : '' }}">
{{ $team->name }}
</button>
@endforeach
</div>
</div>
</div>

7
routes/ai.php Normal file
View file

@ -0,0 +1,7 @@
<?php
use App\Mcp\Servers\CoolifyServer;
use Laravel\Mcp\Facades\Mcp;
Mcp::web('/mcp', CoolifyServer::class)
->middleware(['mcp.enabled', 'auth:sanctum']);

View file

@ -35,6 +35,8 @@
], function () {
Route::get('/enable', [OtherController::class, 'enable_api']);
Route::get('/disable', [OtherController::class, 'disable_api']);
Route::post('/mcp/enable', [OtherController::class, 'enable_mcp']);
Route::post('/mcp/disable', [OtherController::class, 'disable_mcp']);
});
Route::group([
'middleware' => ['auth:sanctum', ApiAllowed::class, 'api.sensitive'],
@ -215,6 +217,8 @@
Route::post('/sentinel/push', function () {
$token = request()->header('Authorization');
if (! $token) {
auditLogWebhookFailure('sentinel', 'token_missing');
return response()->json(['message' => 'Unauthorized'], 401);
}
$naked_token = str_replace('Bearer ', '', $token);
@ -222,26 +226,49 @@
$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);
}
$data = request()->all();
@ -249,6 +276,11 @@
// \App\Jobs\ServerCheckNewJob::dispatch($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);
});
});

650
svgs/jitsi.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show more