mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-25 07:50:35 -05:00
Merge remote-tracking branch 'origin/next' into automation/sync-main-to-next
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
APP_ENV=testing
|
||||
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
|
||||
APP_DEBUG=true
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
|
||||
DB_CONNECTION=testing
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartClickhouse
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneClickhouse $database;
|
||||
|
||||
@@ -16,7 +18,11 @@ class StartClickhouse
|
||||
|
||||
public string $configuration_dir;
|
||||
|
||||
public function handle(StandaloneClickhouse $database)
|
||||
private string $resolvedClickhouseUser;
|
||||
|
||||
private string $resolvedClickhousePassword;
|
||||
|
||||
public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -51,7 +57,7 @@ class StartClickhouse
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
|
||||
'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -109,7 +115,7 @@ class StartClickhouse
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -147,8 +153,17 @@ class StartClickhouse
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
|
||||
$this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'CLICKHOUSE_USER') {
|
||||
$this->resolvedClickhouseUser = $rawValue;
|
||||
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
|
||||
$this->resolvedClickhousePassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class StartDatabase
|
||||
{
|
||||
@@ -22,7 +26,7 @@ class StartDatabase
|
||||
$job->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
@@ -33,32 +37,32 @@ class StartDatabase
|
||||
'last_restart_at' => null,
|
||||
'last_restart_type' => null,
|
||||
]);
|
||||
switch ($database->getMorphClass()) {
|
||||
case StandalonePostgresql::class:
|
||||
$activity = StartPostgresql::run($database);
|
||||
break;
|
||||
case StandaloneRedis::class:
|
||||
$activity = StartRedis::run($database);
|
||||
break;
|
||||
case StandaloneMongodb::class:
|
||||
$activity = StartMongodb::run($database);
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
$activity = StartMysql::run($database);
|
||||
break;
|
||||
case StandaloneMariadb::class:
|
||||
$activity = StartMariadb::run($database);
|
||||
break;
|
||||
case StandaloneKeydb::class:
|
||||
$activity = StartKeydb::run($database);
|
||||
break;
|
||||
case StandaloneDragonfly::class:
|
||||
$activity = StartDragonfly::run($database);
|
||||
break;
|
||||
case StandaloneClickhouse::class:
|
||||
$activity = StartClickhouse::run($database);
|
||||
break;
|
||||
|
||||
$activity = activity()
|
||||
->withProperties([
|
||||
'server_uuid' => $server->uuid,
|
||||
'type' => ActivityTypes::INLINE->value,
|
||||
'type_uuid' => $database->uuid,
|
||||
'status' => ProcessStatus::QUEUED->value,
|
||||
'team_id' => $server->team_id,
|
||||
'operation' => 'database-start',
|
||||
])
|
||||
->performedOn($database)
|
||||
->event(ActivityTypes::INLINE->value)
|
||||
->log('[]');
|
||||
|
||||
if ($activity === null) {
|
||||
return 'Database start could not be queued because activity logging is disabled.';
|
||||
}
|
||||
|
||||
DatabaseStartJob::dispatch(
|
||||
$database->getMorphClass(),
|
||||
(int) $database->getKey(),
|
||||
(int) $database->team()->id,
|
||||
(int) $activity->getKey(),
|
||||
auth()->id(),
|
||||
);
|
||||
|
||||
if ($database->is_public && $database->public_port) {
|
||||
StartDatabaseProxy::dispatch($database);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServiceDatabase;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Throwable;
|
||||
|
||||
class StartDatabaseImport
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public const MAX_BYTES = 10 * 1024 * 1024 * 1024;
|
||||
|
||||
public const LOCK_SECONDS = 1800;
|
||||
|
||||
public function __construct(private readonly DatabaseImportCommandBuilder $commands) {}
|
||||
|
||||
public static function lockKey(string $resourceUuid): string
|
||||
{
|
||||
return "database-import:{$resourceUuid}";
|
||||
}
|
||||
|
||||
public function handle(Model $resource, DatabaseImportSource $source, int $teamId): Activity
|
||||
{
|
||||
if (! $this->commands->supports($resource)) {
|
||||
throw new DatabaseImportException('Database imports are not supported for this database type.');
|
||||
}
|
||||
if (! str($resource->status)->startsWith('running')) {
|
||||
throw new DatabaseImportException('The database must be running before an import can start.');
|
||||
}
|
||||
|
||||
[$server, $container, $network] = $this->target($resource);
|
||||
$destination = $resource instanceof ServiceDatabase ? $resource->service?->destination : $resource->destination;
|
||||
if ($destination instanceof SwarmDocker) {
|
||||
throw new DatabaseImportException('Database imports are not supported for Swarm servers yet.', 501);
|
||||
}
|
||||
if (! $server || ! ValidationPatterns::isValidContainerName($container)) {
|
||||
throw new DatabaseImportException('The database server or container is invalid.', 400);
|
||||
}
|
||||
|
||||
$lock = Cache::lock(self::lockKey($resource->uuid), self::LOCK_SECONDS);
|
||||
|
||||
if (! $lock->get()) {
|
||||
throw new DatabaseImportException('A database import is already running.', 409);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->startImport($resource, $source, $teamId, $server, $container, $network);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function startImport(Model $resource, DatabaseImportSource $source, int $teamId, Server $server, string $container, string $network): Activity
|
||||
{
|
||||
$active = Activity::query()->where('properties->team_id', $teamId)
|
||||
->where('properties->type_uuid', $resource->uuid)
|
||||
->where('properties->operation', 'database_import')
|
||||
->whereIn('properties->status', [ProcessStatus::QUEUED->value, ProcessStatus::IN_PROGRESS->value])
|
||||
->exists();
|
||||
if ($active) {
|
||||
throw new DatabaseImportException('A database import is already running.', 409);
|
||||
}
|
||||
|
||||
$operation = (string) Str::uuid();
|
||||
$containerPath = "/tmp/restore_{$operation}";
|
||||
$scriptPath = "/tmp/restore_{$operation}.sh";
|
||||
$commandList = [];
|
||||
$cleanup = ['container' => $container, 'containerTmpPath' => $containerPath, 'scriptPath' => $scriptPath, 'serverId' => $server->id];
|
||||
|
||||
if ($source->type === 'upload') {
|
||||
$staged = $source->uploadId
|
||||
? "upload/imports/{$teamId}/{$resource->uuid}/{$source->uploadId}/restore"
|
||||
: "upload/{$resource->uuid}/restore";
|
||||
if (! Storage::exists($staged)) {
|
||||
throw new DatabaseImportException('The completed upload was not found.');
|
||||
}
|
||||
$local = Storage::path($staged);
|
||||
if ($this->commands->databaseType($resource) === 'postgresql' && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($local)) {
|
||||
Storage::delete($staged);
|
||||
throw new DatabaseImportException('The uploaded backup contains disallowed PostgreSQL restore directives.');
|
||||
}
|
||||
$serverPath = "/tmp/database-import-{$operation}";
|
||||
instant_scp($local, $serverPath, $server);
|
||||
$source->uploadId ? Storage::deleteDirectory(dirname($staged)) : Storage::delete($staged);
|
||||
$commandList[] = 'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}");
|
||||
$commandList[] = 'rm -f '.escapeshellarg($serverPath);
|
||||
$cleanup['serverTmpPath'] = $serverPath;
|
||||
} elseif ($source->type === 'server') {
|
||||
$this->assertServerPath($source->path);
|
||||
$size = (int) trim((string) instant_remote_process(['stat -c %s -- '.escapeshellarg($source->path)], $server));
|
||||
if ($size < 1 || $size > self::MAX_BYTES) {
|
||||
throw new DatabaseImportException('The backup file is empty or exceeds the 10 GiB limit.');
|
||||
}
|
||||
$commandList[] = 'docker cp '.escapeshellarg($source->path).' '.escapeshellarg("{$container}:{$containerPath}");
|
||||
} else {
|
||||
$storage = S3Storage::ownedByCurrentTeamAPI($teamId)
|
||||
->where(fn ($query) => $query->whereUuid($source->s3StorageUuid)->orWhere('id', ctype_digit((string) $source->s3StorageUuid) ? (int) $source->s3StorageUuid : -1))
|
||||
->where('is_usable', true)->first();
|
||||
if (! $storage || ! ValidationPatterns::isValidS3BucketName($storage->bucket)) {
|
||||
throw new DatabaseImportException('S3 storage was not found or has an invalid bucket.');
|
||||
}
|
||||
$key = ltrim((string) $source->path, '/');
|
||||
$this->assertS3Path($key);
|
||||
$disk = $storage->filesystem();
|
||||
if (! $disk->exists($key) || $disk->size($key) > self::MAX_BYTES) {
|
||||
throw new DatabaseImportException('The S3 backup was not found or exceeds the 10 GiB limit.');
|
||||
}
|
||||
$helper = "s3-restore-{$operation}";
|
||||
$serverPath = "/tmp/s3-restore-{$operation}";
|
||||
$this->startS3HelperWithEnv($storage, $server, $helper, $network);
|
||||
$sourceArg = escapeshellarg("s3temp/{$storage->bucket}/{$key}");
|
||||
$commandList = [
|
||||
'docker exec '.escapeshellarg($helper).' sh -c '.escapeshellarg('mc alias set s3temp "$S3_ENDPOINT" "$S3_ACCESS_KEY" "$S3_SECRET_KEY"'),
|
||||
'docker exec '.escapeshellarg($helper).' mc cp '.$sourceArg.' /tmp/restore',
|
||||
'docker cp '.escapeshellarg("{$helper}:/tmp/restore").' '.escapeshellarg($serverPath),
|
||||
'docker cp '.escapeshellarg($serverPath).' '.escapeshellarg("{$container}:{$containerPath}"),
|
||||
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
|
||||
'rm -f '.escapeshellarg($serverPath),
|
||||
];
|
||||
$cleanup += ['containerName' => $helper, 'serverTmpPath' => $serverPath];
|
||||
}
|
||||
|
||||
if ($safety = $this->commands->buildPostgresSafetyCommand($resource, $container, $containerPath)) {
|
||||
$commandList[] = $safety;
|
||||
}
|
||||
$restore = base64_encode($this->commands->buildRestoreCommand($resource, $containerPath, $source->dumpAll, $source->replaceExisting));
|
||||
$commandList[] = 'echo '.escapeshellarg($restore).' | base64 -d > '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'chmod +x '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'docker cp '.escapeshellarg($scriptPath).' '.escapeshellarg("{$container}:{$scriptPath}");
|
||||
$commandList[] = 'rm -f '.escapeshellarg($scriptPath);
|
||||
$commandList[] = 'docker exec '.escapeshellarg($container).' sh -c '.escapeshellarg($scriptPath);
|
||||
|
||||
$activity = remote_process($commandList, $server, type_uuid: $resource->uuid, model: $resource, callEventOnFinish: 'DatabaseImportFinished', callEventData: $cleanup);
|
||||
$activity->properties = $activity->properties->merge(['operation' => 'database_import', 'resource_kind' => $resource instanceof ServiceDatabase ? 'service_database' : 'standalone_database', 'operation_uuid' => $operation]);
|
||||
$activity->save();
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function target(Model $resource): array
|
||||
{
|
||||
if ($resource instanceof ServiceDatabase) {
|
||||
return [$resource->service?->server, $resource->name.'-'.$resource->service?->uuid, $resource->service?->destination?->network ?? 'coolify'];
|
||||
}
|
||||
|
||||
return [$resource->destination?->server, $resource->uuid, $resource->destination?->network ?? 'coolify'];
|
||||
}
|
||||
|
||||
private function assertServerPath(?string $path): void
|
||||
{
|
||||
if (! $path || ! str_starts_with($path, '/') || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
|
||||
throw new DatabaseImportException('The server path is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertS3Path(string $path): void
|
||||
{
|
||||
if ($path === '' || preg_match('/\.\.|[$()`|;&><\r\n\0\'"\\\\]/', $path) || ! DatabaseBackupFileValidator::hasAllowedExtension(basename($path))) {
|
||||
throw new DatabaseImportException('The S3 path is invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
private function startS3HelperWithEnv(S3Storage $storage, Server $server, string $helper, string $network): void
|
||||
{
|
||||
$image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
'docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true',
|
||||
'docker run -d --network '.escapeshellarg($network)
|
||||
.' --name '.escapeshellarg($helper)
|
||||
.' -e S3_ENDPOINT='.escapeshellarg((string) $storage->endpoint)
|
||||
.' -e S3_ACCESS_KEY='.escapeshellarg((string) $storage->key)
|
||||
.' -e S3_SECRET_KEY='.escapeshellarg((string) $storage->secret)
|
||||
.' '.$image.' sleep 3600',
|
||||
], $server);
|
||||
} catch (Throwable) {
|
||||
instant_remote_process(['docker rm -f '.escapeshellarg($helper).' 2>/dev/null || true'], $server, throwError: false);
|
||||
|
||||
throw new DatabaseImportException('Unable to start the S3 restore helper.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartDragonfly
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneDragonfly $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartDragonfly
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneDragonfly $database)
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -107,7 +111,7 @@ class StartDragonfly
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
|
||||
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -196,12 +200,13 @@ class StartDragonfly
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
|
||||
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
|
||||
$command = "dragonfly --requirepass {$escapedRedisPassword}";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$sslArgs = [
|
||||
@@ -251,8 +256,14 @@ class StartDragonfly
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartKeydb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneKeydb $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartKeydb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneKeydb $database)
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneKeydb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -109,7 +113,7 @@ class StartKeydb
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
|
||||
'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -214,7 +218,7 @@ class StartKeydb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -252,8 +256,14 @@ class StartKeydb
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
@@ -280,6 +290,7 @@ class StartKeydb
|
||||
{
|
||||
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
|
||||
$keydbConfPath = '/etc/keydb/keydb.conf';
|
||||
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
|
||||
|
||||
if ($hasKeydbConf) {
|
||||
$confContent = $this->database->keydb_conf;
|
||||
@@ -288,10 +299,10 @@ class StartKeydb
|
||||
if ($hasRequirePass) {
|
||||
$command = "keydb-server $keydbConfPath";
|
||||
} else {
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
|
||||
}
|
||||
} else {
|
||||
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
|
||||
$command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMariadb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMariadb $database;
|
||||
|
||||
@@ -20,7 +22,7 @@ class StartMariadb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMariadb $database)
|
||||
public function handle(StandaloneMariadb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -216,7 +218,7 @@ class StartMariadb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -255,7 +257,7 @@ class StartMariadb
|
||||
{
|
||||
$environment_variables = collect();
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMongodb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMongodb $database;
|
||||
|
||||
@@ -20,7 +22,13 @@ class StartMongodb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMongodb $database)
|
||||
private string $resolvedMongoUsername;
|
||||
|
||||
private string $resolvedMongoPassword;
|
||||
|
||||
private string $resolvedMongoDatabase;
|
||||
|
||||
public function handle(StandaloneMongodb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -265,7 +273,7 @@ class StartMongodb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -303,8 +311,20 @@ class StartMongodb
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
|
||||
$this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
|
||||
$this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
|
||||
$this->resolvedMongoUsername = $rawValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
|
||||
$this->resolvedMongoPassword = $rawValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
|
||||
$this->resolvedMongoDatabase = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
|
||||
@@ -337,9 +357,9 @@ class StartMongodb
|
||||
|
||||
private function add_default_database()
|
||||
{
|
||||
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
|
||||
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
|
||||
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
|
||||
$dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
|
||||
$userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
|
||||
$pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
|
||||
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
|
||||
$content_base64 = base64_encode($content);
|
||||
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMysql
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMysql $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartMysql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMysql $database)
|
||||
private string $resolvedMysqlRootPassword;
|
||||
|
||||
public function handle(StandaloneMysql $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -104,7 +108,7 @@ class StartMysql
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
|
||||
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -218,7 +222,7 @@ class StartMysql
|
||||
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -256,8 +260,14 @@ class StartMysql
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
|
||||
$this->resolvedMysqlRootPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartPostgresql
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandalonePostgresql $database;
|
||||
|
||||
@@ -22,7 +24,11 @@ class StartPostgresql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandalonePostgresql $database)
|
||||
private string $resolvedPostgresUser;
|
||||
|
||||
private string $resolvedPostgresDatabase;
|
||||
|
||||
public function handle(StandalonePostgresql $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
$container_name = $this->database->uuid;
|
||||
@@ -111,7 +117,7 @@ class StartPostgresql
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
|
||||
'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -227,7 +233,7 @@ class StartPostgresql
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -265,8 +271,17 @@ class StartPostgresql
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedPostgresUser = (string) $this->database->postgres_user;
|
||||
$this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'POSTGRES_USER') {
|
||||
$this->resolvedPostgresUser = $rawValue;
|
||||
} elseif ($env->key === 'POSTGRES_DB') {
|
||||
$this->resolvedPostgresDatabase = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartRedis
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneRedis $database;
|
||||
|
||||
@@ -20,7 +22,11 @@ class StartRedis
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneRedis $database)
|
||||
private ?string $resolvedRedisPassword = null;
|
||||
|
||||
private ?string $resolvedRedisUsername = null;
|
||||
|
||||
public function handle(StandaloneRedis $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -209,7 +215,7 @@ class StartRedis
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -249,23 +255,40 @@ class StartRedis
|
||||
$environment_variables = collect();
|
||||
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
|
||||
if ($env->is_shared) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->database->update(['redis_password' => $env->real_value]);
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($env->key === 'REDIS_USERNAME') {
|
||||
$this->database->update(['redis_username' => $env->real_value]);
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_password]);
|
||||
} elseif ($env->key === 'REDIS_USERNAME') {
|
||||
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_username]);
|
||||
}
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
} elseif ($env->key === 'REDIS_USERNAME') {
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
}
|
||||
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +299,7 @@ class StartRedis
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
|
||||
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
|
||||
$redisConfPath = '/usr/local/etc/redis/redis.conf';
|
||||
|
||||
@@ -286,10 +310,10 @@ class StartRedis
|
||||
if ($hasRequirePass) {
|
||||
$command = "redis-server $redisConfPath";
|
||||
} else {
|
||||
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
|
||||
$command = "redis-server $redisConfPath --requirepass {$redisPassword}";
|
||||
}
|
||||
} else {
|
||||
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
|
||||
$command = "redis-server --requirepass {$redisPassword} --appendonly yes";
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
||||
@@ -30,7 +30,7 @@ class CreateNewUser implements CreatesNewUsers
|
||||
public function create(array $input): User
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
if (! $settings->is_registration_enabled) {
|
||||
if (! $settings->isPasswordRegistrationAllowed()) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class CheckUpdates
|
||||
@@ -106,6 +107,15 @@ class CheckUpdates
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
case 'apk':
|
||||
instant_remote_process(['apk update -q'], $server);
|
||||
$output = instant_remote_process(['LANG=C apk list --upgradable 2>/dev/null'], $server);
|
||||
|
||||
$out = $this->parseApkOutput($output);
|
||||
$out['osId'] = $osId;
|
||||
$out['package_manager'] = $packageManager;
|
||||
|
||||
return $out;
|
||||
default:
|
||||
return [
|
||||
@@ -266,11 +276,39 @@ class CheckUpdates
|
||||
// Include unparsed lines in the result for debugging if any exist
|
||||
if (! empty($unparsedLines)) {
|
||||
$result['unparsed_lines'] = $unparsedLines;
|
||||
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
|
||||
Log::debug('Pacman output contained unparsed lines', [
|
||||
'unparsed_lines' => $unparsedLines,
|
||||
]);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function parseApkOutput(string $output): array
|
||||
{
|
||||
$updates = [];
|
||||
$lines = explode("\n", $output);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
// Skip empty lines
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Example line: docker-cli-compose-2.31.0-r5 x86_64 {docker-cli-compose} (Apache-2.0) [upgradable from: docker-cli-compose-2.31.0-r4]
|
||||
if (preg_match('/^(.+)-([0-9]\S*) (\S+) \{\S+\} \([^)]+\) \[upgradable from: .+?-([0-9][^\]]+)\]$/', $line, $matches)) {
|
||||
$updates[] = [
|
||||
'package' => $matches[1],
|
||||
'new_version' => $matches[2],
|
||||
'architecture' => $matches[3],
|
||||
'current_version' => $matches[4],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total_updates' => count($updates),
|
||||
'updates' => $updates,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Server;
|
||||
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use App\Jobs\RestartProxyJob;
|
||||
use App\Models\Server;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class ConfigureTrafficAnalytics
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public function handle(Server $server, bool $enable): void
|
||||
{
|
||||
$sentinelWasEnabled = (bool) $server->settings->is_sentinel_enabled;
|
||||
|
||||
$server->settings->is_traffic_analytics_enabled = $enable;
|
||||
$server->settings->save();
|
||||
$server->refresh();
|
||||
|
||||
// Regenerate proxy config so the (Traefik) access-log flags / (Caddy) log labels take effect.
|
||||
GetProxyConfiguration::run($server, forceRegenerate: true);
|
||||
RestartProxyJob::dispatch($server);
|
||||
|
||||
// Recreate Sentinel so it picks up (enabling) or drops (disabling) the traffic env + proxy-log mount.
|
||||
// Enabling analytics needs Sentinel running; when disabling, only restart if Sentinel was already
|
||||
// enabled so we never turn Sentinel on as a side effect of disabling analytics.
|
||||
if ($enable || $sentinelWasEnabled) {
|
||||
StartSentinel::run($server, restart: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,8 @@ class InstallDocker
|
||||
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('arch')) {
|
||||
$command = $command->merge([$this->getArchDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('alpine')) {
|
||||
$command = $command->merge([$this->getAlpineDockerInstallCommand()]);
|
||||
} else {
|
||||
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
||||
}
|
||||
@@ -93,9 +95,8 @@ class InstallDocker
|
||||
"jq -s '.[0] * .[1]' /etc/docker/daemon.json.coolify /etc/docker/daemon.json | tee /etc/docker/daemon.json.appended > /dev/null",
|
||||
'mv /etc/docker/daemon.json.appended /etc/docker/daemon.json',
|
||||
"echo 'Restarting Docker Engine...'",
|
||||
'systemctl enable docker >/dev/null 2>&1 || true',
|
||||
'systemctl restart docker',
|
||||
]);
|
||||
$command = $command->merge($this->getDockerServiceCommands($supported_os_type->contains('alpine')));
|
||||
if ($server->isSwarm()) {
|
||||
$command = $command->merge([
|
||||
'docker network create --attachable --driver overlay coolify-overlay >/dev/null 2>&1 || true',
|
||||
@@ -154,6 +155,28 @@ class InstallDocker
|
||||
'systemctl start docker.service';
|
||||
}
|
||||
|
||||
private function getAlpineDockerInstallCommand(): string
|
||||
{
|
||||
return 'apk update && '.
|
||||
'apk add docker docker-cli-buildx docker-cli-compose && '.
|
||||
'mkdir -p /etc/docker';
|
||||
}
|
||||
|
||||
private function getDockerServiceCommands(bool $usesOpenRc): array
|
||||
{
|
||||
if ($usesOpenRc) {
|
||||
return [
|
||||
'rc-update add docker default',
|
||||
'rc-service docker restart',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'systemctl enable docker >/dev/null 2>&1 || true',
|
||||
'systemctl restart docker',
|
||||
];
|
||||
}
|
||||
|
||||
private function getGenericDockerInstallCommand(): string
|
||||
{
|
||||
return 'curl -fsSL https://get.docker.com | sh';
|
||||
|
||||
@@ -53,6 +53,8 @@ class InstallPrerequisites
|
||||
"echo 'Installing Prerequisites for Arch Linux...'",
|
||||
'pacman -Syu --noconfirm --needed curl wget git jq',
|
||||
]);
|
||||
} elseif ($supported_os_type->contains('alpine')) {
|
||||
$command = $command->merge($this->getAlpinePrerequisiteCommands());
|
||||
} else {
|
||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||
}
|
||||
@@ -61,4 +63,18 @@ class InstallPrerequisites
|
||||
|
||||
return remote_process($command, $server);
|
||||
}
|
||||
|
||||
private function getAlpinePrerequisiteCommands(): array
|
||||
{
|
||||
return [
|
||||
"echo 'Installing Prerequisites for Alpine Linux...'",
|
||||
"sed -i '/^#.*\\/community/s/^#//' /etc/apk/repositories 2>/dev/null || true",
|
||||
'apk update',
|
||||
'command -v bash >/dev/null || apk add bash',
|
||||
'command -v curl >/dev/null || apk add curl',
|
||||
'command -v wget >/dev/null || apk add wget',
|
||||
'command -v git >/dev/null || apk add git',
|
||||
'command -v jq >/dev/null || apk add jq',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,40 @@ class StartSentinel
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
public static function trafficLogDirectory(Server $server): string
|
||||
{
|
||||
return isDev()
|
||||
? '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/proxy'
|
||||
: rtrim($server->proxyPath(), '/');
|
||||
}
|
||||
|
||||
public static function sentinelTrafficEnvironment(Server $server): array
|
||||
{
|
||||
if (! $server->isTrafficAnalyticsEnabled()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$logPath = self::trafficLogDirectory($server).'/access.log';
|
||||
$settings = $server->settings;
|
||||
$env = [
|
||||
'TRAFFIC_ENABLED' => 'true',
|
||||
'TRAFFIC_PROXY_TYPE' => 'auto',
|
||||
'TRAFFIC_ACCESS_LOG_PATH' => $logPath,
|
||||
'TRAFFIC_TOPN' => (string) ($settings->traffic_topn ?: 50),
|
||||
'TRAFFIC_SAMPLE_THRESHOLD' => (string) ($settings->traffic_sample_threshold ?? 0),
|
||||
'TRAFFIC_RETENTION_1H_DAYS' => (string) ($settings->traffic_retention_1h_days ?: 30),
|
||||
'TRAFFIC_RETENTION_1D_DAYS' => (string) ($settings->traffic_retention_1d_days ?: 395),
|
||||
'GEOIP_ENABLED' => $settings->is_geoip_enabled ? 'true' : 'false',
|
||||
'GEOIP_REFRESH_DAYS' => (string) ($settings->geoip_refresh_days ?: 30),
|
||||
];
|
||||
$license = data_get($settings, 'geoip_maxmind_license_key');
|
||||
if ($settings->is_geoip_enabled && filled($license)) {
|
||||
$env['GEOIP_MAXMIND_LICENSE_KEY'] = $license;
|
||||
}
|
||||
|
||||
return $env;
|
||||
}
|
||||
|
||||
public function handle(Server $server, bool $restart = false, ?string $latestVersion = null, ?string $customImage = null)
|
||||
{
|
||||
if ($server->isSwarm() || $server->isBuildServer()) {
|
||||
@@ -36,6 +70,7 @@ class StartSentinel
|
||||
'COLLECTOR_REFRESH_RATE_SECONDS' => $refreshRate,
|
||||
'COLLECTOR_RETENTION_PERIOD_DAYS' => $metricsHistory,
|
||||
];
|
||||
$environments = array_merge($environments, self::sentinelTrafficEnvironment($server));
|
||||
$labels = [
|
||||
'coolify.managed' => 'true',
|
||||
];
|
||||
@@ -48,7 +83,11 @@ class StartSentinel
|
||||
}
|
||||
$dockerEnvironments = implode(' ', array_map(fn ($key, $value) => '-e '.escapeshellarg("$key=$value"), array_keys($environments), $environments));
|
||||
$dockerLabels = implode(' ', array_map(fn ($key, $value) => "$key=$value", array_keys($labels), $labels));
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db --pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
$trafficLogDirectory = self::trafficLogDirectory($server);
|
||||
$trafficMount = $server->isTrafficAnalyticsEnabled()
|
||||
? '-v '.escapeshellarg("{$trafficLogDirectory}:{$trafficLogDirectory}:ro").' '
|
||||
: '';
|
||||
$dockerCommand = "docker run -d $dockerEnvironments --name coolify-sentinel -v /var/run/docker.sock:/var/run/docker.sock -v $mountDir:/app/db {$trafficMount}--pid host --health-cmd \"curl --fail http://127.0.0.1:8888/api/health || exit 1\" --health-start-period 120s --health-interval 10s --health-retries 3 --add-host=host.docker.internal:host-gateway --label $dockerLabels $image";
|
||||
|
||||
instant_remote_process([
|
||||
'docker rm -f coolify-sentinel || true',
|
||||
|
||||
@@ -58,6 +58,10 @@ class UpdatePackage
|
||||
$commandAll = 'pacman -Syu --noconfirm';
|
||||
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
|
||||
break;
|
||||
case 'apk':
|
||||
$commandAll = 'apk update && apk upgrade';
|
||||
$commandInstall = 'apk upgrade '.$sanitizedPackage;
|
||||
break;
|
||||
default:
|
||||
return [
|
||||
'error' => 'OS not supported',
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcDiscoveryException extends OidcException {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class OidcException extends RuntimeException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcJwksException extends OidcException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcSigningKeyNotFoundException extends OidcTokenException {}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Exceptions;
|
||||
|
||||
class OidcTokenException extends OidcException {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Models\OauthSetting;
|
||||
|
||||
final readonly class OidcConfig
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $scopes
|
||||
*/
|
||||
public function __construct(
|
||||
public string $issuerUrl,
|
||||
public string $clientId,
|
||||
public string $clientSecret,
|
||||
public string $redirectUri,
|
||||
public array $scopes = ['openid', 'email', 'profile'],
|
||||
public bool $usePkce = true,
|
||||
public int $clockSkewSeconds = 60,
|
||||
) {}
|
||||
|
||||
public static function fromOauthSetting(OauthSetting $setting): self
|
||||
{
|
||||
return new self(
|
||||
issuerUrl: rtrim((string) $setting->base_url, '/'),
|
||||
clientId: (string) $setting->client_id,
|
||||
clientSecret: (string) $setting->client_secret,
|
||||
redirectUri: filled($setting->redirect_uri) ? $setting->redirect_uri : route('auth.callback', 'oidc'),
|
||||
scopes: $setting->scopeList(),
|
||||
usePkce: $setting->use_pkce ?? true,
|
||||
clockSkewSeconds: $setting->clock_skew_seconds ?? 60,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
|
||||
|
||||
final readonly class OidcDiscoveryDocument
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $supportedScopes
|
||||
* @param array<int, string> $supportedClaims
|
||||
* @param array<int, string> $idTokenSigningAlgValuesSupported
|
||||
*/
|
||||
public function __construct(
|
||||
public string $issuer,
|
||||
public string $authorizationEndpoint,
|
||||
public string $tokenEndpoint,
|
||||
public string $userinfoEndpoint,
|
||||
public string $jwksUri,
|
||||
public ?string $endSessionEndpoint = null,
|
||||
public array $supportedScopes = [],
|
||||
public array $supportedClaims = [],
|
||||
public array $idTokenSigningAlgValuesSupported = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromArray(array $payload): self
|
||||
{
|
||||
foreach (['issuer', 'authorization_endpoint', 'token_endpoint', 'userinfo_endpoint', 'jwks_uri'] as $field) {
|
||||
if (! is_string($payload[$field] ?? null) || trim($payload[$field]) === '') {
|
||||
throw new OidcDiscoveryException("Discovery document is missing required field: {$field}");
|
||||
}
|
||||
}
|
||||
|
||||
return new self(
|
||||
issuer: $payload['issuer'],
|
||||
authorizationEndpoint: $payload['authorization_endpoint'],
|
||||
tokenEndpoint: $payload['token_endpoint'],
|
||||
userinfoEndpoint: $payload['userinfo_endpoint'],
|
||||
jwksUri: $payload['jwks_uri'],
|
||||
endSessionEndpoint: is_string($payload['end_session_endpoint'] ?? null) ? $payload['end_session_endpoint'] : null,
|
||||
supportedScopes: self::stringList($payload['scopes_supported'] ?? []),
|
||||
supportedClaims: self::stringList($payload['claims_supported'] ?? []),
|
||||
idTokenSigningAlgValuesSupported: self::stringList($payload['id_token_signing_alg_values_supported'] ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private static function stringList(mixed $value): array
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('strval', $value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcDiscoveryException;
|
||||
use App\Auth\Oidc\Exceptions\OidcJwksException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
class OidcDiscoveryService
|
||||
{
|
||||
public function discover(string $issuerUrl): OidcDiscoveryDocument
|
||||
{
|
||||
$this->assertHttpsUrl($issuerUrl, new OidcDiscoveryException('Issuer URL must be an absolute HTTPS URL.'));
|
||||
|
||||
$issuerUrl = rtrim($issuerUrl, '/');
|
||||
$cacheKey = 'oidc:discovery:'.hash('sha256', $issuerUrl);
|
||||
|
||||
return Cache::remember($cacheKey, 3600, function () use ($issuerUrl): OidcDiscoveryDocument {
|
||||
$url = $issuerUrl.'/.well-known/openid-configuration';
|
||||
|
||||
try {
|
||||
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new OidcDiscoveryException("Discovery endpoint returned HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || $json === []) {
|
||||
throw new OidcDiscoveryException('Discovery endpoint returned invalid JSON.');
|
||||
}
|
||||
|
||||
$discovery = OidcDiscoveryDocument::fromArray($json);
|
||||
if (rtrim($discovery->issuer, '/') !== $issuerUrl) {
|
||||
throw new OidcDiscoveryException('Discovery issuer does not match the configured issuer URL.');
|
||||
}
|
||||
|
||||
return $discovery;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the JWKS for the given URI.
|
||||
*
|
||||
* When $forceRefresh is true the cached document is bypassed so freshly
|
||||
* rotated signing keys become visible immediately. A short cooldown still
|
||||
* prevents a flood of upstream requests if many logins miss the same kid.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function jwks(string $jwksUri, bool $forceRefresh = false): array
|
||||
{
|
||||
$this->assertHttpsUrl($jwksUri, new OidcJwksException('JWKS URI must be an absolute HTTPS URL.'));
|
||||
|
||||
$cacheKey = 'oidc:jwks:'.hash('sha256', $jwksUri);
|
||||
|
||||
if ($forceRefresh) {
|
||||
$cooldownKey = $cacheKey.':refresh';
|
||||
if (Cache::add($cooldownKey, true, 60)) {
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
|
||||
try {
|
||||
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new OidcJwksException("JWKS endpoint returned HTTP {$response->status()}");
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
if (! is_array($json) || ! is_array($json['keys'] ?? null)) {
|
||||
throw new OidcJwksException("JWKS endpoint returned an invalid payload without 'keys'.");
|
||||
}
|
||||
|
||||
return $json;
|
||||
});
|
||||
}
|
||||
|
||||
private function assertHttpsUrl(string $url, Throwable $exception): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
|
||||
if (($parts['scheme'] ?? null) !== 'https' || ! is_string($parts['host'] ?? null) || $parts['host'] === '') {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
|
||||
use App\Auth\Oidc\Exceptions\OidcTokenException;
|
||||
use Firebase\JWT\JWK;
|
||||
use Firebase\JWT\JWT;
|
||||
use Throwable;
|
||||
|
||||
class OidcTokenValidator
|
||||
{
|
||||
/**
|
||||
* Algorithms we accept for id_token signatures. RS256 only — this is the
|
||||
* OIDC baseline and a strict allowlist prevents algorithm-confusion and
|
||||
* "none" attacks.
|
||||
*/
|
||||
private const ALLOWED_ALGORITHM = 'RS256';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validate(
|
||||
string $idToken,
|
||||
OidcDiscoveryDocument $discovery,
|
||||
array $jwks,
|
||||
string $clientId,
|
||||
?string $expectedNonce = null,
|
||||
int $clockSkewSeconds = 60,
|
||||
): array {
|
||||
$kid = $this->extractKid($idToken);
|
||||
|
||||
try {
|
||||
$keys = JWK::parseKeySet($this->signingKeysOnly($jwks), self::ALLOWED_ALGORITHM);
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcTokenException("Unable to parse JWKS: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
// Surface an unknown signing key distinctly so the caller can refresh
|
||||
// the JWKS once (key rotation) before giving up.
|
||||
if (! array_key_exists($kid, $keys)) {
|
||||
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
|
||||
}
|
||||
|
||||
$previousLeeway = JWT::$leeway;
|
||||
JWT::$leeway = $clockSkewSeconds;
|
||||
|
||||
try {
|
||||
// Validates signature, header alg against the key alg (RS256),
|
||||
// exp, nbf and iat. Throws on any failure.
|
||||
$claims = (array) JWT::decode($idToken, $keys);
|
||||
} catch (OidcTokenException $e) {
|
||||
throw $e;
|
||||
} catch (Throwable $e) {
|
||||
throw new OidcTokenException("id_token validation failed: {$e->getMessage()}", previous: $e);
|
||||
} finally {
|
||||
JWT::$leeway = $previousLeeway;
|
||||
}
|
||||
|
||||
$this->assertExpiry($claims);
|
||||
$this->assertIssuer($claims, $discovery->issuer);
|
||||
$this->assertAudience($claims, $clientId);
|
||||
$this->assertNonce($claims, $expectedNonce);
|
||||
$this->assertSubject($claims);
|
||||
|
||||
return $claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop JWKS entries explicitly marked for anything other than signing
|
||||
* (e.g. "use":"enc") so they can never verify an id_token signature.
|
||||
* firebase/php-jwt does not honour the "use" parameter on its own.
|
||||
*
|
||||
* @param array<string, mixed> $jwks
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function signingKeysOnly(array $jwks): array
|
||||
{
|
||||
$keys = array_values(array_filter(
|
||||
$jwks['keys'] ?? [],
|
||||
fn ($jwk): bool => is_array($jwk) && (! isset($jwk['use']) || $jwk['use'] === 'sig'),
|
||||
));
|
||||
|
||||
return ['keys' => $keys];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode just the JWT header to read the kid before signature
|
||||
* verification, so an unknown key can be reported as a rotation miss.
|
||||
*/
|
||||
private function extractKid(string $idToken): string
|
||||
{
|
||||
$segments = explode('.', $idToken);
|
||||
if (count($segments) !== 3) {
|
||||
throw new OidcTokenException('Malformed id_token.');
|
||||
}
|
||||
|
||||
$header = json_decode($this->base64UrlDecode($segments[0]), true);
|
||||
if (! is_array($header)) {
|
||||
throw new OidcTokenException('id_token header contains invalid JSON.');
|
||||
}
|
||||
|
||||
if (($header['alg'] ?? null) !== self::ALLOWED_ALGORITHM) {
|
||||
throw new OidcTokenException('id_token uses a disallowed algorithm.');
|
||||
}
|
||||
|
||||
$kid = $header['kid'] ?? null;
|
||||
if (! is_string($kid) || $kid === '') {
|
||||
throw new OidcTokenException('id_token header is missing kid.');
|
||||
}
|
||||
|
||||
return $kid;
|
||||
}
|
||||
|
||||
private function base64UrlDecode(string $value): string
|
||||
{
|
||||
$remainder = strlen($value) % 4;
|
||||
if ($remainder !== 0) {
|
||||
$value .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
|
||||
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
|
||||
if ($decoded === false) {
|
||||
throw new OidcTokenException('Invalid base64url value in id_token header.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertExpiry(array $claims): void
|
||||
{
|
||||
// Firebase enforces the exp window when present; OIDC requires it to exist.
|
||||
if (! is_numeric($claims['exp'] ?? null)) {
|
||||
throw new OidcTokenException('id_token is missing the exp claim.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertSubject(array $claims): void
|
||||
{
|
||||
$subject = $claims['sub'] ?? null;
|
||||
if (! is_string($subject) || $subject === '') {
|
||||
throw new OidcTokenException('id_token subject is missing or invalid.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertIssuer(array $claims, string $expectedIssuer): void
|
||||
{
|
||||
if (($claims['iss'] ?? null) !== $expectedIssuer) {
|
||||
throw new OidcTokenException('id_token issuer does not match discovery issuer.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertAudience(array $claims, string $clientId): void
|
||||
{
|
||||
$audience = $claims['aud'] ?? null;
|
||||
if (is_string($audience)) {
|
||||
$audience = [$audience];
|
||||
}
|
||||
|
||||
if (! is_array($audience) || ! in_array($clientId, $audience, true)) {
|
||||
throw new OidcTokenException('id_token audience does not include configured client id.');
|
||||
}
|
||||
|
||||
if (count($audience) > 1 && (! isset($claims['azp']) || $claims['azp'] !== $clientId)) {
|
||||
throw new OidcTokenException('id_token azp is required when aud contains multiple values and must match configured client id.');
|
||||
}
|
||||
|
||||
if (isset($claims['azp']) && $claims['azp'] !== $clientId) {
|
||||
throw new OidcTokenException('id_token azp does not match configured client id.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
private function assertNonce(array $claims, ?string $expectedNonce): void
|
||||
{
|
||||
if ($expectedNonce === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (($claims['nonce'] ?? null) !== $expectedNonce) {
|
||||
throw new OidcTokenException('id_token nonce does not match.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc;
|
||||
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
|
||||
class OidcUser extends SocialiteUser
|
||||
{
|
||||
public ?string $issuer = null;
|
||||
|
||||
public ?string $subject = null;
|
||||
|
||||
public bool $emailVerified = false;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $idTokenClaims = [];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claims
|
||||
*/
|
||||
public function setIdTokenClaims(array $claims): self
|
||||
{
|
||||
$this->idTokenClaims = $claims;
|
||||
$this->issuer = is_string($claims['iss'] ?? null) ? $claims['iss'] : null;
|
||||
$this->subject = is_string($claims['sub'] ?? null) ? $claims['sub'] : null;
|
||||
$this->emailVerified = ($claims['email_verified'] ?? false) === true;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
namespace App\Auth\Oidc\Socialite;
|
||||
|
||||
use App\Auth\Oidc\Exceptions\OidcException;
|
||||
use App\Auth\Oidc\Exceptions\OidcSigningKeyNotFoundException;
|
||||
use App\Auth\Oidc\OidcConfig;
|
||||
use App\Auth\Oidc\OidcDiscoveryDocument;
|
||||
use App\Auth\Oidc\OidcDiscoveryService;
|
||||
use App\Auth\Oidc\OidcTokenValidator;
|
||||
use App\Auth\Oidc\OidcUser;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\InvalidStateException;
|
||||
use Laravel\Socialite\Two\ProviderInterface;
|
||||
|
||||
class OidcProvider extends AbstractProvider implements ProviderInterface
|
||||
{
|
||||
private const int OIDC_FLOW_TTL_MINUTES = 10;
|
||||
|
||||
/**
|
||||
* @var array<int, string>
|
||||
*/
|
||||
protected $scopes = ['openid', 'email', 'profile'];
|
||||
|
||||
protected $scopeSeparator = ' ';
|
||||
|
||||
protected ?OidcConfig $oidcConfig = null;
|
||||
|
||||
protected ?OidcDiscoveryDocument $discovery = null;
|
||||
|
||||
public function __construct(
|
||||
Request $request,
|
||||
protected OidcDiscoveryService $discoveryService,
|
||||
protected OidcTokenValidator $tokenValidator,
|
||||
string $clientId,
|
||||
string $clientSecret,
|
||||
string $redirectUrl,
|
||||
) {
|
||||
parent::__construct($request, $clientId, $clientSecret, $redirectUrl);
|
||||
}
|
||||
|
||||
public function setConfig(OidcConfig $config): self
|
||||
{
|
||||
$this->oidcConfig = $config;
|
||||
$this->clientId = $config->clientId;
|
||||
$this->clientSecret = $config->clientSecret;
|
||||
$this->redirectUrl = $config->redirectUri;
|
||||
$this->scopes = $config->scopes;
|
||||
$this->discovery = null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getConfig(): OidcConfig
|
||||
{
|
||||
if ($this->oidcConfig === null) {
|
||||
throw new OidcException('OIDC provider config is not set.');
|
||||
}
|
||||
|
||||
return $this->oidcConfig;
|
||||
}
|
||||
|
||||
protected function getAuthUrl($state): string
|
||||
{
|
||||
$config = $this->getConfig();
|
||||
$nonce = Str::random(40);
|
||||
$this->putOidcFlowValue($this->nonceSessionKey($state), $nonce);
|
||||
|
||||
$extra = ['nonce' => $nonce];
|
||||
if ($config->usePkce) {
|
||||
$verifier = $this->generateCodeVerifier();
|
||||
$this->putOidcFlowValue($this->verifierSessionKey($state), $verifier);
|
||||
$extra['code_challenge'] = $this->codeChallenge($verifier);
|
||||
$extra['code_challenge_method'] = 'S256';
|
||||
}
|
||||
|
||||
return $this->buildAuthUrlFromBase($this->resolveDiscovery()->authorizationEndpoint, $state)
|
||||
.'&'.http_build_query($extra, '', '&', $this->encodingType);
|
||||
}
|
||||
|
||||
protected function getTokenUrl(): string
|
||||
{
|
||||
return $this->resolveDiscovery()->tokenEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getUserByToken($token): array
|
||||
{
|
||||
$response = $this->getHttpClient()->get($this->resolveDiscovery()->userinfoEndpoint, [
|
||||
RequestOptions::HEADERS => [
|
||||
'Accept' => 'application/json',
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
RequestOptions::CONNECT_TIMEOUT => 5,
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
protected function mapUserToObject(array $user)
|
||||
{
|
||||
return (new OidcUser)->setRaw($user)->map([
|
||||
'id' => $user['sub'] ?? null,
|
||||
'nickname' => $user['preferred_username'] ?? null,
|
||||
'name' => $this->resolveName($user),
|
||||
'email' => $user['email'] ?? null,
|
||||
'avatar' => $user['picture'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
if ($this->user) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
if ($this->hasInvalidState()) {
|
||||
throw new InvalidStateException;
|
||||
}
|
||||
|
||||
$tokenResponse = $this->getAccessTokenResponse($this->getCode());
|
||||
$accessToken = Arr::get($tokenResponse, 'access_token');
|
||||
$idToken = Arr::get($tokenResponse, 'id_token');
|
||||
|
||||
if (! is_string($accessToken) || $accessToken === '' || ! is_string($idToken) || $idToken === '') {
|
||||
throw new OidcException('OIDC token endpoint did not return required tokens.');
|
||||
}
|
||||
|
||||
$discovery = $this->resolveDiscovery();
|
||||
$config = $this->getConfig();
|
||||
$expectedNonce = $this->pullOidcFlowValue($this->nonceSessionKey((string) $this->request->input('state')));
|
||||
if ($expectedNonce === null) {
|
||||
throw new OidcException('OIDC login session expired. Please try again.');
|
||||
}
|
||||
|
||||
$claims = $this->validateIdToken($idToken, $discovery, $config, $expectedNonce);
|
||||
|
||||
$userinfo = $this->getUserByToken($accessToken);
|
||||
|
||||
// OIDC core §5.3.2: the userinfo sub MUST match the id_token sub.
|
||||
// Reject the response rather than trust unsigned userinfo claims.
|
||||
$userinfoSub = $userinfo['sub'] ?? null;
|
||||
if (is_string($userinfoSub) && $userinfoSub !== '' && $userinfoSub !== ($claims['sub'] ?? null)) {
|
||||
throw new OidcException('OIDC userinfo subject does not match the id_token subject.');
|
||||
}
|
||||
|
||||
$merged = array_merge($userinfo, $claims);
|
||||
|
||||
/** @var OidcUser $user */
|
||||
$user = $this->mapUserToObject($merged);
|
||||
$user->setIdTokenClaims($claims)
|
||||
->setToken($accessToken)
|
||||
->setRefreshToken(Arr::get($tokenResponse, 'refresh_token'))
|
||||
->setExpiresIn(Arr::get($tokenResponse, 'expires_in'));
|
||||
|
||||
return $this->user = $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the id_token, retrying once against a freshly fetched JWKS when
|
||||
* the signing key is unknown. This keeps logins working immediately after
|
||||
* the IdP rotates keys instead of failing until the JWKS cache expires.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function validateIdToken(
|
||||
string $idToken,
|
||||
OidcDiscoveryDocument $discovery,
|
||||
OidcConfig $config,
|
||||
?string $expectedNonce,
|
||||
): array {
|
||||
foreach ([false, true] as $forceRefresh) {
|
||||
try {
|
||||
return $this->tokenValidator->validate(
|
||||
idToken: $idToken,
|
||||
discovery: $discovery,
|
||||
jwks: $this->discoveryService->jwks($discovery->jwksUri, $forceRefresh),
|
||||
clientId: $config->clientId,
|
||||
expectedNonce: $expectedNonce,
|
||||
clockSkewSeconds: $config->clockSkewSeconds,
|
||||
);
|
||||
} catch (OidcSigningKeyNotFoundException $e) {
|
||||
if ($forceRefresh) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new OidcSigningKeyNotFoundException('No matching JWKS key found for id_token kid.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getAccessTokenResponse($code)
|
||||
{
|
||||
$fields = $this->getTokenFields($code);
|
||||
if ($this->getConfig()->usePkce) {
|
||||
$verifier = $this->pullOidcFlowValue($this->verifierSessionKey((string) $this->request->input('state')));
|
||||
if ($verifier === null) {
|
||||
throw new OidcException('OIDC login session expired. Please try again.');
|
||||
}
|
||||
|
||||
$fields['code_verifier'] = $verifier;
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
|
||||
RequestOptions::HEADERS => ['Accept' => 'application/json'],
|
||||
RequestOptions::FORM_PARAMS => $fields,
|
||||
RequestOptions::CONNECT_TIMEOUT => 5,
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
]);
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true);
|
||||
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
protected function resolveDiscovery(): OidcDiscoveryDocument
|
||||
{
|
||||
return $this->discovery ??= $this->discoveryService->discover($this->getConfig()->issuerUrl);
|
||||
}
|
||||
|
||||
protected function generateCodeVerifier(): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(random_bytes(64)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
protected function codeChallenge(string $verifier): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $user
|
||||
*/
|
||||
protected function resolveName(array $user): ?string
|
||||
{
|
||||
if (is_string($user['name'] ?? null) && $user['name'] !== '') {
|
||||
return $user['name'];
|
||||
}
|
||||
|
||||
$name = trim(((string) ($user['given_name'] ?? '')).' '.((string) ($user['family_name'] ?? '')));
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
protected function putOidcFlowValue(string $key, string $value): void
|
||||
{
|
||||
$this->request->session()->put($key, [
|
||||
'value' => $value,
|
||||
'expires_at' => now()->addMinutes(self::OIDC_FLOW_TTL_MINUTES)->timestamp,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function pullOidcFlowValue(string $key): ?string
|
||||
{
|
||||
$entry = $this->request->session()->pull($key);
|
||||
|
||||
if (! is_array($entry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $entry['value'] ?? null;
|
||||
$expiresAt = $entry['expires_at'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '' || ! is_int($expiresAt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($expiresAt < now()->timestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
protected function nonceSessionKey(string $state): string
|
||||
{
|
||||
return "oidc.nonce.{$state}";
|
||||
}
|
||||
|
||||
protected function verifierSessionKey(string $state): string
|
||||
{
|
||||
return "oidc.code_verifier.{$state}";
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -49,6 +50,12 @@ class CleanupDatabase extends Command
|
||||
$activity_log->delete();
|
||||
}
|
||||
|
||||
$count = DB::table('audit_events')->where('created_at', '<', now()->subDays(90))->count();
|
||||
echo "Delete $count entries from audit_events.\n";
|
||||
if ($this->option('yes')) {
|
||||
AuditEvent::pruneExpired();
|
||||
}
|
||||
|
||||
// Cleanup application_deployment_queues table
|
||||
$application_deployment_queues = DB::table('application_deployment_queues')->where('created_at', '<', now()->subDays($keep_days))->orderBy('created_at', 'desc')->skip(10);
|
||||
$count = $application_deployment_queues->count();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Traffic;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class TrafficBreakdownData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public string $value,
|
||||
public int $requests,
|
||||
public int $bytesOut,
|
||||
) {}
|
||||
|
||||
public static function fromSentinel(array $row): self
|
||||
{
|
||||
return new self(
|
||||
value: (string) data_get($row, 'value', ''),
|
||||
requests: (int) data_get($row, 'requests', 0),
|
||||
bytesOut: (int) data_get($row, 'bytes_out', 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Traffic;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class TrafficOverviewData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public int $requests,
|
||||
public int $bytesIn,
|
||||
public int $bytesOut,
|
||||
public int $s2xx,
|
||||
public int $s3xx,
|
||||
public int $s4xx,
|
||||
public int $s5xx,
|
||||
public float $latencyP50,
|
||||
public float $latencyP95,
|
||||
public float $latencyP99,
|
||||
public int $uniqueVisitors,
|
||||
) {}
|
||||
|
||||
public static function fromSentinel(array $json): self
|
||||
{
|
||||
return new self(
|
||||
requests: (int) data_get($json, 'requests', 0),
|
||||
bytesIn: (int) data_get($json, 'bytes_in', 0),
|
||||
bytesOut: (int) data_get($json, 'bytes_out', 0),
|
||||
s2xx: (int) data_get($json, 'status.s2xx', 0),
|
||||
s3xx: (int) data_get($json, 'status.s3xx', 0),
|
||||
s4xx: (int) data_get($json, 'status.s4xx', 0),
|
||||
s5xx: (int) data_get($json, 'status.s5xx', 0),
|
||||
latencyP50: (float) data_get($json, 'latency.p50', 0.0),
|
||||
latencyP95: (float) data_get($json, 'latency.p95', 0.0),
|
||||
latencyP99: (float) data_get($json, 'latency.p99', 0.0),
|
||||
uniqueVisitors: (int) data_get($json, 'unique_visitors', 0),
|
||||
);
|
||||
}
|
||||
|
||||
public static function zero(): self
|
||||
{
|
||||
return new self(0, 0, 0, 0, 0, 0, 0, 0.0, 0.0, 0.0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Traffic;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class TrafficPathData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
public string $path,
|
||||
public int $requests,
|
||||
public int $bytesOut,
|
||||
public int $s4xx,
|
||||
public int $s5xx,
|
||||
public float $p50,
|
||||
public float $p95,
|
||||
// Owning app key (Sentinel returns this per path row so the UI can show the
|
||||
// domain). Empty on older Sentinel builds that predate the field.
|
||||
public string $app = '',
|
||||
) {}
|
||||
|
||||
public static function fromSentinel(array $row): self
|
||||
{
|
||||
return new self(
|
||||
path: (string) data_get($row, 'path', ''),
|
||||
requests: (int) data_get($row, 'requests', 0),
|
||||
bytesOut: (int) data_get($row, 'bytes_out', 0),
|
||||
s4xx: (int) data_get($row, 's4xx', 0),
|
||||
s5xx: (int) data_get($row, 's5xx', 0),
|
||||
p50: (float) data_get($row, 'p50', 0.0),
|
||||
p95: (float) data_get($row, 'p95', 0.0),
|
||||
app: (string) data_get($row, 'app', ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Traffic;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class TrafficSeriesBucketData extends Data
|
||||
{
|
||||
public function __construct(
|
||||
// Unix-millis start of the bucket (hour- or day-aligned by the request's range).
|
||||
public int $bucket,
|
||||
public int $s2xx,
|
||||
public int $s3xx,
|
||||
public int $s4xx,
|
||||
public int $s5xx,
|
||||
// Per-bucket aggregates (added to Sentinel's series alongside the status classes)
|
||||
// so every KPI card can draw a real sparkline. Older Sentinel omits these; they
|
||||
// default to 0 and requests falls back to the status-class sum.
|
||||
public int $requests = 0,
|
||||
public int $bytesIn = 0,
|
||||
public int $bytesOut = 0,
|
||||
public int $uniqueVisitors = 0,
|
||||
public float $p95 = 0.0,
|
||||
) {}
|
||||
|
||||
public static function fromSentinel(array $row): self
|
||||
{
|
||||
$s2xx = (int) data_get($row, 's2xx', 0);
|
||||
$s3xx = (int) data_get($row, 's3xx', 0);
|
||||
$s4xx = (int) data_get($row, 's4xx', 0);
|
||||
$s5xx = (int) data_get($row, 's5xx', 0);
|
||||
|
||||
return new self(
|
||||
bucket: (int) data_get($row, 'bucket', 0),
|
||||
s2xx: $s2xx,
|
||||
s3xx: $s3xx,
|
||||
s4xx: $s4xx,
|
||||
s5xx: $s5xx,
|
||||
requests: (int) data_get($row, 'requests', $s2xx + $s3xx + $s4xx + $s5xx),
|
||||
bytesIn: (int) data_get($row, 'bytes_in', 0),
|
||||
bytesOut: (int) data_get($row, 'bytes_out', 0),
|
||||
uniqueVisitors: (int) data_get($row, 'unique_visitors', 0),
|
||||
p95: (float) data_get($row, 'p95', 0),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DatabaseImportFinished
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly array $data) {}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class DnsRecordConfigurationFinished implements ShouldBroadcastNow
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public ?string $resourceType,
|
||||
public int|string|null $resourceId,
|
||||
public string $hostname,
|
||||
public bool $successful,
|
||||
public string $credential,
|
||||
public string $message,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel("team.{$this->teamId}")];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class DnsRecordConflictException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $providerRecordId,
|
||||
public readonly string $currentValue,
|
||||
public readonly string $proposedValue,
|
||||
) {
|
||||
parent::__construct("DNS record already points to {$currentValue}.");
|
||||
}
|
||||
}
|
||||
@@ -243,12 +243,18 @@ class SshMultiplexingHelper
|
||||
|
||||
$delimiter = base64_encode(Hash::make($command));
|
||||
$command = str_replace($delimiter, '', $command);
|
||||
$remoteShellCommand = self::remoteShellCommand();
|
||||
|
||||
return $sshCommand.self::escapedUserAtHost($server)." 'bash -se' << \\$delimiter".PHP_EOL
|
||||
return $sshCommand.self::escapedUserAtHost($server)." '{$remoteShellCommand}' << \\$delimiter".PHP_EOL
|
||||
.$command.PHP_EOL
|
||||
.$delimiter;
|
||||
}
|
||||
|
||||
private static function remoteShellCommand(): string
|
||||
{
|
||||
return 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi';
|
||||
}
|
||||
|
||||
public static function getConnectionTimeout(Server $server): int
|
||||
{
|
||||
$timeout = data_get($server, 'settings.connection_timeout');
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ApplicationSecretManagerController extends Controller
|
||||
{
|
||||
#[OA\Patch(
|
||||
summary: 'Configure Application Secret Manager',
|
||||
description: 'Configure the secret manager source used by an application.',
|
||||
path: '/applications/{uuid}/secret-manager',
|
||||
operationId: 'configure-application-secret-manager',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Secret Managers'],
|
||||
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['integration_token_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'integration_token_uuid', type: 'string'),
|
||||
new OA\Property(property: 'settings', type: 'object'),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Secret manager configured.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)
|
||||
->where('uuid', $request->route('uuid'))
|
||||
->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$body = $request->json()->all();
|
||||
$token = IntegrationToken::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('uuid', $body['integration_token_uuid'] ?? '')
|
||||
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
|
||||
->first();
|
||||
|
||||
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
|
||||
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'integration_token_uuid' => ['required', 'string'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
$rules += match ($token->provider) {
|
||||
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
|
||||
'settings.project' => ['required', 'string'],
|
||||
'settings.config' => ['required', 'string'],
|
||||
] : [],
|
||||
'infisical' => [
|
||||
'settings.project_id' => ['required', 'string'],
|
||||
'settings.environment' => ['required', 'string'],
|
||||
'settings.secret_path' => ['nullable', 'string'],
|
||||
],
|
||||
'vault' => [
|
||||
'settings.mount' => ['required', 'string'],
|
||||
'settings.path' => ['required', 'string'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$validator = customApiValidator($body, $rules);
|
||||
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
|
||||
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
|
||||
$application->secretManagerLink()->updateOrCreate([], [
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
|
||||
auditLog('api.application.secret_manager.updated', [
|
||||
'team_id' => $teamId,
|
||||
'application_uuid' => $application->uuid,
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
'provider' => $token->provider,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\LocalFileVolume;
|
||||
@@ -61,6 +62,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options',
|
||||
'is_consistent_container_name_enabled',
|
||||
'custom_internal_name',
|
||||
'custom_container_name_prefix',
|
||||
];
|
||||
|
||||
private const BOOLEAN_APPLICATION_SETTING_FIELDS = [
|
||||
@@ -153,9 +155,26 @@ class ApplicationsController extends Controller
|
||||
: $request->input($field);
|
||||
}
|
||||
|
||||
if (array_key_exists('custom_container_name_prefix', $settings)) {
|
||||
$settings['custom_container_name_prefix'] = str($settings['custom_container_name_prefix'])->slug()->value() ?: null;
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private function containerNamePrefixValidationResponse(array $settings, Server $server, ?Application $application = null): ?JsonResponse
|
||||
{
|
||||
$prefix = $settings['custom_container_name_prefix'] ?? null;
|
||||
if (! filled($prefix) || ! ApplicationSetting::isContainerNamePrefixInUse($prefix, $server, $application?->id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['custom_container_name_prefix' => ['This container name prefix is already in use by another application.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
private function applyApplicationSettings(Application $application, array $settings): void
|
||||
{
|
||||
if ($settings === []) {
|
||||
@@ -393,6 +412,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
@@ -587,6 +607,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
@@ -781,6 +802,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
@@ -946,6 +968,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
@@ -1107,6 +1130,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'is_http_basic_auth_enabled' => ['type' => 'boolean', 'description' => 'HTTP Basic Authentication enabled.'],
|
||||
@@ -1335,6 +1359,9 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $destination->server)) {
|
||||
return $prefixValidation;
|
||||
}
|
||||
if ($type === 'public') {
|
||||
$validationRules = [
|
||||
'git_repository' => ['string', 'required', new ValidGitRepositoryUrl],
|
||||
@@ -2969,6 +2996,7 @@ class ApplicationsController extends Controller
|
||||
'gpu_options' => ['type' => 'string', 'nullable' => true, 'description' => 'Additional GPU options.'],
|
||||
'is_consistent_container_name_enabled' => ['type' => 'boolean', 'description' => 'Use a consistent container name across deployments.'],
|
||||
'custom_internal_name' => ['type' => 'string', 'nullable' => true, 'description' => 'Custom internal container name.'],
|
||||
'custom_container_name_prefix' => ['type' => 'string', 'nullable' => true, 'description' => 'Prefix for generated container names (prefix-20260908T141530). Slugified and unique across the instance.'],
|
||||
'preview_url_template' => ['type' => 'string', 'description' => 'Preview URL template.'],
|
||||
'max_restart_count' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Maximum container restart count before stopping.'],
|
||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||
@@ -3142,6 +3170,9 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$applicationSettings = $this->applicationSettingsFromRequest($request);
|
||||
if ($prefixValidation = $this->containerNamePrefixValidationResponse($applicationSettings, $application->destination->server, $application)) {
|
||||
return $prefixValidation;
|
||||
}
|
||||
$requestedBuildPack = $request->input('build_pack', $application->build_pack);
|
||||
if (($applicationSettings['is_raw_compose_deployment_enabled'] ?? false) && $requestedBuildPack !== 'dockercompose') {
|
||||
return response()->json([
|
||||
@@ -3395,7 +3426,7 @@ class ApplicationsController extends Controller
|
||||
if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
}
|
||||
$application->save();
|
||||
$application->withoutAuditLogging(fn () => $application->save());
|
||||
|
||||
auditLog('api.application.updated', [
|
||||
'team_id' => $teamId,
|
||||
@@ -5903,14 +5934,6 @@ class ApplicationsController extends Controller
|
||||
return response()->json(['message' => $result['message']], 200);
|
||||
}
|
||||
|
||||
auditLog('api.application.rollback', [
|
||||
'team_id' => $teamId,
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
'commit' => $commit,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Rollback deployment queued.',
|
||||
'deployment_uuid' => $deployment_uuid,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditEvent;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AuditEventsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $request->user()->isAdminOfTeam($teamId)) {
|
||||
return response()->json(['message' => 'Only team admins and owners can view audit logs.'], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||
'page' => ['sometimes', 'integer', 'min:1'],
|
||||
'search' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'action' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'source' => ['sometimes', 'nullable', 'string', Rule::in(['all', 'ui', 'api', 'mcp', 'webhook', 'system', 'scheduler'])],
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$perPage = (int) ($validated['per_page'] ?? 25);
|
||||
$search = trim((string) ($validated['search'] ?? ''));
|
||||
$canReadSensitive = $request->attributes->get('can_read_sensitive', false) === true;
|
||||
$events = AuditEvent::query()
|
||||
->select([
|
||||
'id',
|
||||
'team_id',
|
||||
'event',
|
||||
'source',
|
||||
'action',
|
||||
'actor_type',
|
||||
'actor_id',
|
||||
'actor_name',
|
||||
'resource_type',
|
||||
'resource_uuid',
|
||||
'resource_name',
|
||||
'description',
|
||||
'created_at',
|
||||
])
|
||||
->when($canReadSensitive, fn ($query) => $query->addSelect([
|
||||
'actor_email',
|
||||
'actor_token_id',
|
||||
'actor_token_name',
|
||||
'metadata',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
]))
|
||||
->visibleToTeam($teamId)
|
||||
->filtered(
|
||||
search: $search,
|
||||
action: (string) ($validated['action'] ?? 'all'),
|
||||
source: (string) ($validated['source'] ?? 'all'),
|
||||
searchSensitiveFields: $canReadSensitive,
|
||||
)
|
||||
->latestFirst()
|
||||
->paginate($perPage);
|
||||
|
||||
return response()->json(serializeApiResponse($events));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Actions\CoolifyTask\RunRemoteProcess;
|
||||
use App\Actions\Database\StartDatabaseImport;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Pion\Laravel\ChunkUpload\Handler\HandlerFactory;
|
||||
use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait HandlesDatabaseImportsApi
|
||||
{
|
||||
protected function uploadDatabaseImport(Request $request, Model $resource, int $teamId): JsonResponse
|
||||
{
|
||||
$this->authorize('uploadBackup', $resource);
|
||||
$validator = Validator::make($request->all(), ['upload_id' => ['required', 'uuid'], 'file' => ['required', 'file']]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422);
|
||||
}
|
||||
$originalName = $request->file('file')?->getClientOriginalName();
|
||||
if (! $originalName || ! DatabaseBackupFileValidator::hasAllowedExtension($originalName)) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Unsupported backup file extension.']]], 422);
|
||||
}
|
||||
if ((int) $request->input('dzTotalFilesize', 0) > StartDatabaseImport::MAX_BYTES) {
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['The backup exceeds the 10 GiB limit.']]], 422);
|
||||
}
|
||||
|
||||
$request->merge(['dzuuid' => $request->input('dzuuid', $request->string('upload_id')->value())]);
|
||||
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
|
||||
$save = $receiver->receive();
|
||||
if (! $save->isFinished()) {
|
||||
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'done' => $save->handler()->getPercentageDone(), 'status' => true]);
|
||||
}
|
||||
|
||||
$file = $save->getFile();
|
||||
if (! $file instanceof UploadedFile || ! DatabaseBackupFileValidator::isUploadAllowed($file, StartDatabaseImport::MAX_BYTES)) {
|
||||
@unlink($file->getPathname());
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => ['file' => ['Uploaded file failed validation.']]], 422);
|
||||
}
|
||||
$mimeType = $file->getMimeType();
|
||||
$size = $file->getSize();
|
||||
$directory = "upload/imports/{$teamId}/{$resource->uuid}/{$request->string('upload_id')->value()}";
|
||||
Storage::makeDirectory($directory);
|
||||
$file->move(Storage::path($directory), 'restore');
|
||||
|
||||
return response()->json(['upload_id' => $request->string('upload_id')->value(), 'filename' => $originalName, 'mime_type' => $mimeType, 'size' => $size], 201);
|
||||
}
|
||||
|
||||
protected function startDatabaseImport(Request $request, Model $resource, int $teamId, string $statusRoute, array $routeParameters): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $resource);
|
||||
$payload = $request->json()->all() ?: $request->request->all();
|
||||
$allowed = ['source', 'upload_id', 's3_storage_uuid', 'path', 'dump_all', 'replace_existing'];
|
||||
$validator = Validator::make($payload, [
|
||||
'source' => ['required', Rule::in(['upload', 's3', 'server'])],
|
||||
'upload_id' => ['required_if:source,upload', 'prohibited_unless:source,upload', 'uuid'],
|
||||
's3_storage_uuid' => ['required_if:source,s3', 'prohibited_unless:source,s3', 'string'],
|
||||
'path' => ['required_if:source,s3,server', 'prohibited_if:source,upload', 'string', 'max:4096'],
|
||||
'dump_all' => ['sometimes', 'boolean'],
|
||||
'replace_existing' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
$extraFields = array_diff(array_keys($payload), $allowed);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$source = new DatabaseImportSource((string) $payload['source'], $payload['upload_id'] ?? null, $payload['path'] ?? null, $payload['s3_storage_uuid'] ?? null, (bool) ($payload['dump_all'] ?? false), (bool) ($payload['replace_existing'] ?? false));
|
||||
$activity = app(StartDatabaseImport::class)->handle($resource, $source, $teamId);
|
||||
} catch (DatabaseImportException $exception) {
|
||||
return response()->json(['message' => $exception->getMessage()], $exception->status);
|
||||
}
|
||||
auditLog('api.database.import_started', [
|
||||
'team_id' => $teamId,
|
||||
'database_uuid' => $resource->uuid,
|
||||
'database_name' => $resource->name,
|
||||
'source' => $source->type,
|
||||
'replace_existing' => $source->replaceExisting,
|
||||
'activity_id' => $activity->id,
|
||||
]);
|
||||
$url = route($statusRoute, [...$routeParameters, 'activity_id' => $activity->id], false);
|
||||
|
||||
return response()->json(['id' => $activity->id, 'status' => data_get($activity, 'properties.status'), 'message' => 'Database import queued.', 'status_url' => $url], 202)->header('Location', $url);
|
||||
}
|
||||
|
||||
protected function showDatabaseImport(Model $resource, int $teamId, int $activityId): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $resource);
|
||||
$activity = Activity::query()->whereKey($activityId)
|
||||
->where('properties->team_id', $teamId)
|
||||
->where('properties->type_uuid', $resource->uuid)
|
||||
->where('properties->operation', 'database_import')->first();
|
||||
if (! $activity) {
|
||||
return response()->json(['message' => 'Database import not found.'], 404);
|
||||
}
|
||||
$status = data_get($activity, 'properties.status');
|
||||
$terminal = in_array($status, ['finished', 'error', 'killed', 'cancelled', 'closed'], true);
|
||||
|
||||
return response()->json([
|
||||
'id' => $activity->id,
|
||||
'status' => $status,
|
||||
'exit_code' => data_get($activity, 'properties.exitCode'),
|
||||
'output' => remove_iip(RunRemoteProcess::decodeOutput($activity)),
|
||||
'created_at' => $activity->created_at,
|
||||
'updated_at' => $activity->updated_at,
|
||||
'finished_at' => $terminal ? $activity->updated_at : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,87 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
use Concerns\HandlesTagsApi;
|
||||
|
||||
#[OA\Post(
|
||||
path: '/databases/{uuid}/imports/uploads',
|
||||
operationId: 'upload-database-import',
|
||||
summary: 'Upload database import',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Upload completed'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function upload_import(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->uploadDatabaseImport($request, $database, $teamId) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/databases/{uuid}/imports',
|
||||
operationId: 'create-database-import',
|
||||
summary: 'Import database backup',
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 202, description: 'Import queued'),
|
||||
new OA\Response(response: 409, description: 'Import already active'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_import(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->startDatabaseImport($request, $database, $teamId, 'api.databases.imports.show', ['uuid' => $uuid]) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/databases/{uuid}/imports/{activity_id}',
|
||||
operationId: 'get-database-import',
|
||||
summary: 'Get database import status',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'activity_id', in: 'path', required: true, description: 'Import activity ID.', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function show_import(Request $request, string $uuid, int $activity_id): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
return $database ? $this->showDatabaseImport($database, $teamId, $activity_id) : response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
return queryDatabaseByUuidWithinTeam($uuid, $teamId);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class IntegrationTokensController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
summary: 'Create Secret Manager Token',
|
||||
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
|
||||
path: '/security/integration-tokens',
|
||||
operationId: 'create-secret-manager-integration-token',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Secret Managers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['provider', 'name', 'token'],
|
||||
properties: [
|
||||
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'token', type: 'string'),
|
||||
new OA\Property(property: 'metadata', type: 'object'),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Integration token created.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
$rules = [
|
||||
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'metadata' => ['sometimes', 'array'],
|
||||
];
|
||||
|
||||
if (($body['provider'] ?? null) === 'doppler') {
|
||||
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
|
||||
} elseif (($body['provider'] ?? null) === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
} elseif (($body['provider'] ?? null) === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
$validator = customApiValidator($body, $rules);
|
||||
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
|
||||
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
|
||||
|
||||
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
|
||||
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
|
||||
}
|
||||
|
||||
$integrationToken = IntegrationToken::query()->create([
|
||||
'team_id' => $teamId,
|
||||
'provider' => $validated['provider'],
|
||||
'name' => $validated['name'],
|
||||
'token' => $validated['token'],
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => $metadata ?: null,
|
||||
]);
|
||||
|
||||
auditLog('api.integration_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'integration_token_uuid' => $integrationToken->uuid,
|
||||
'provider' => $integrationToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json(['uuid' => $integrationToken->uuid], 201);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,30 @@ use OpenApi\Attributes as OA;
|
||||
securityScheme: 'bearerAuth',
|
||||
description: 'Go to `Keys & Tokens` / `API tokens` and create a new token. Use the token as the bearer token.')]
|
||||
#[OA\Components(
|
||||
schemas: [
|
||||
new OA\Schema(
|
||||
schema: 'DatabaseImportRequest',
|
||||
oneOf: [
|
||||
new OA\Schema(required: ['source', 'upload_id'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['upload']), new OA\Property(property: 'upload_id', type: 'string', format: 'uuid'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
|
||||
new OA\Schema(required: ['source', 's3_storage_uuid', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['s3']), new OA\Property(property: 's3_storage_uuid', type: 'string'), new OA\Property(property: 'path', type: 'string'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
|
||||
new OA\Schema(required: ['source', 'path'], additionalProperties: false, properties: [new OA\Property(property: 'source', type: 'string', enum: ['server']), new OA\Property(property: 'path', type: 'string', example: '/var/backups/database.sql.gz'), new OA\Property(property: 'dump_all', type: 'boolean', default: false), new OA\Property(property: 'replace_existing', description: 'Drop matching PostgreSQL objects before restoring a single-database archive.', type: 'boolean', default: false)]),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
new OA\Schema(
|
||||
schema: 'DatabaseImportStatus',
|
||||
type: 'object',
|
||||
properties: [
|
||||
new OA\Property(property: 'id', type: 'integer'),
|
||||
new OA\Property(property: 'status', type: 'string', enum: ['queued', 'in_progress', 'finished', 'error', 'killed', 'cancelled', 'closed']),
|
||||
new OA\Property(property: 'exit_code', type: 'integer', nullable: true),
|
||||
new OA\Property(property: 'output', type: 'string'),
|
||||
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
|
||||
new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(
|
||||
response: 400,
|
||||
|
||||
@@ -271,12 +271,6 @@ class ProjectController extends Controller
|
||||
'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);
|
||||
@@ -396,13 +390,6 @@ class ProjectController extends Controller
|
||||
|
||||
$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,
|
||||
@@ -482,16 +469,8 @@ class ProjectController extends Controller
|
||||
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.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,13 @@ class ServerSentinelController extends Controller
|
||||
'sentinel_metrics_history_days',
|
||||
'sentinel_push_interval_seconds',
|
||||
'sentinel_custom_url',
|
||||
'traffic_topn',
|
||||
'traffic_sample_threshold',
|
||||
'traffic_retention_1h_days',
|
||||
'traffic_retention_1d_days',
|
||||
'is_geoip_enabled',
|
||||
'geoip_refresh_days',
|
||||
'geoip_maxmind_license_key',
|
||||
];
|
||||
|
||||
private function findServerForTeam(int $teamId, string $uuid): ?Server
|
||||
@@ -42,11 +49,18 @@ class ServerSentinelController extends Controller
|
||||
'sentinel_metrics_history_days' => (int) $settings->sentinel_metrics_history_days,
|
||||
'sentinel_push_interval_seconds' => (int) $settings->sentinel_push_interval_seconds,
|
||||
'sentinel_updated_at' => $server->sentinel_updated_at,
|
||||
'traffic_topn' => (int) $settings->traffic_topn,
|
||||
'traffic_sample_threshold' => (int) $settings->traffic_sample_threshold,
|
||||
'traffic_retention_1h_days' => (int) $settings->traffic_retention_1h_days,
|
||||
'traffic_retention_1d_days' => (int) $settings->traffic_retention_1d_days,
|
||||
'is_geoip_enabled' => (bool) $settings->is_geoip_enabled,
|
||||
'geoip_refresh_days' => (int) $settings->geoip_refresh_days,
|
||||
];
|
||||
|
||||
if ($this->canReadSensitive()) {
|
||||
$payload['sentinel_token'] = $settings->sentinel_token;
|
||||
$payload['sentinel_custom_url'] = $settings->sentinel_custom_url;
|
||||
$payload['geoip_maxmind_license_key'] = $settings->geoip_maxmind_license_key;
|
||||
}
|
||||
|
||||
return $payload;
|
||||
@@ -77,6 +91,13 @@ class ServerSentinelController extends Controller
|
||||
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer'),
|
||||
new OA\Property(property: 'sentinel_custom_url', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
new OA\Property(property: 'sentinel_updated_at', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'traffic_topn', type: 'integer'),
|
||||
new OA\Property(property: 'traffic_sample_threshold', type: 'integer'),
|
||||
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer'),
|
||||
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer'),
|
||||
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'geoip_refresh_days', type: 'integer'),
|
||||
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', description: 'Only present with read:sensitive.'),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
@@ -124,6 +145,13 @@ class ServerSentinelController extends Controller
|
||||
new OA\Property(property: 'sentinel_metrics_history_days', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'sentinel_push_interval_seconds', type: 'integer', minimum: 10),
|
||||
new OA\Property(property: 'sentinel_custom_url', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'traffic_topn', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'traffic_sample_threshold', type: 'integer', minimum: 0),
|
||||
new OA\Property(property: 'traffic_retention_1h_days', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'traffic_retention_1d_days', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'is_geoip_enabled', type: 'boolean'),
|
||||
new OA\Property(property: 'geoip_refresh_days', type: 'integer', minimum: 1),
|
||||
new OA\Property(property: 'geoip_maxmind_license_key', type: 'string', nullable: true),
|
||||
],
|
||||
type: 'object',
|
||||
),
|
||||
@@ -163,6 +191,13 @@ class ServerSentinelController extends Controller
|
||||
'sentinel_metrics_history_days' => 'integer|min:1',
|
||||
'sentinel_push_interval_seconds' => 'integer|min:10',
|
||||
'sentinel_custom_url' => 'nullable|url',
|
||||
'traffic_topn' => 'integer|min:1',
|
||||
'traffic_sample_threshold' => 'integer|min:0',
|
||||
'traffic_retention_1h_days' => 'integer|min:1',
|
||||
'traffic_retention_1d_days' => 'integer|min:1',
|
||||
'is_geoip_enabled' => 'boolean',
|
||||
'geoip_refresh_days' => 'integer|min:1',
|
||||
'geoip_maxmind_license_key' => 'nullable|string|max:255',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), self::ALLOWED_FIELDS);
|
||||
|
||||
@@ -18,6 +18,84 @@ use OpenApi\Attributes as OA;
|
||||
|
||||
class ServiceDatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
|
||||
#[OA\Post(
|
||||
path: '/services/{uuid}/databases/{database_uuid}/imports/uploads',
|
||||
operationId: 'upload-service-database-import',
|
||||
summary: 'Upload service database import',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Upload completed'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function upload_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->uploadDatabaseImport($request, $database, $teamId));
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
path: '/services/{uuid}/databases/{database_uuid}/imports',
|
||||
operationId: 'create-service-database-import',
|
||||
summary: 'Import service database backup',
|
||||
requestBody: new OA\RequestBody(required: true, content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportRequest')),
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 202, description: 'Import queued'),
|
||||
new OA\Response(response: 409, description: 'Import already active'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function create_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->startDatabaseImport($request, $database, $teamId, 'api.service-databases.imports.show', ['uuid' => $request->route('uuid'), 'database_uuid' => $database->uuid]));
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
path: '/services/{uuid}/databases/{database_uuid}/imports/{activity_id}',
|
||||
operationId: 'get-service-database-import',
|
||||
summary: 'Get service database import status',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Service databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', description: 'Service UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'database_uuid', in: 'path', description: 'Service database UUID.', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'activity_id', in: 'path', description: 'Import activity ID.', required: true, schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Import status', content: new OA\JsonContent(ref: '#/components/schemas/DatabaseImportStatus')),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function show_import(Request $request): JsonResponse
|
||||
{
|
||||
return $this->withImportDatabase($request, fn (ServiceDatabase $database, int $teamId) => $this->showDatabaseImport($database, $teamId, (int) $request->route('activity_id')));
|
||||
}
|
||||
|
||||
private function withImportDatabase(Request $request, callable $callback): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$service = $this->resolveService($request, $teamId);
|
||||
$database = $service ? $this->resolveServiceDatabase($request, $service) : null;
|
||||
|
||||
return $database ? $callback($database, $teamId) : response()->json(['message' => 'Service database not found.'], 404);
|
||||
}
|
||||
|
||||
private function removeSensitiveData(ServiceDatabase $serviceDatabase): array
|
||||
{
|
||||
$serviceDatabase->makeHidden([
|
||||
|
||||
@@ -2,41 +2,27 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use App\Models\OauthSetting;
|
||||
use App\Services\Auth\OauthLoginService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class OauthController extends Controller
|
||||
{
|
||||
public function redirect(string $provider)
|
||||
{
|
||||
$socialite_provider = get_socialite_provider($provider);
|
||||
$oauthSetting = $this->enabledProvider($provider);
|
||||
$socialiteProvider = get_socialite_provider($oauthSetting->provider);
|
||||
|
||||
return $socialite_provider->redirect();
|
||||
return $socialiteProvider->redirect();
|
||||
}
|
||||
|
||||
public function callback(string $provider)
|
||||
public function callback(string $provider, OauthLoginService $oauthLoginService)
|
||||
{
|
||||
try {
|
||||
$oauthUser = get_socialite_provider($provider)->user();
|
||||
$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) {
|
||||
abort(403, 'Registration is disabled');
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'name' => $oauthUser->name,
|
||||
'email' => $email,
|
||||
]);
|
||||
}
|
||||
Auth::login($user);
|
||||
$oauthSetting = $this->enabledProvider($provider);
|
||||
$oauthUser = get_socialite_provider($oauthSetting->provider)->user();
|
||||
$oauthLoginService->login($oauthSetting->provider, $oauthUser, $oauthSetting);
|
||||
|
||||
$team = $user->resolveStoredTeam();
|
||||
if (! $team && $user->teams()->count() === 0) {
|
||||
@@ -48,9 +34,36 @@ class OauthController extends Controller
|
||||
|
||||
return redirect('/');
|
||||
} catch (\Exception $e) {
|
||||
$this->logCallbackFailure($provider, $e);
|
||||
|
||||
$errorCode = $e instanceof HttpException ? 'auth.failed' : 'auth.failed.callback';
|
||||
|
||||
return redirect()->route('login')->withErrors([__($errorCode)]);
|
||||
}
|
||||
}
|
||||
|
||||
private function logCallbackFailure(string $provider, \Throwable $exception): void
|
||||
{
|
||||
Log::error('OAuth callback failed.', [
|
||||
'provider' => $provider,
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => $exception->getMessage(),
|
||||
'request_error' => request()->query('error'),
|
||||
'request_error_description' => request()->query('error_description'),
|
||||
'has_code' => request()->query->has('code'),
|
||||
'has_state' => request()->query->has('state'),
|
||||
'ip' => request()->ip(),
|
||||
'exception' => $exception,
|
||||
]);
|
||||
}
|
||||
|
||||
private function enabledProvider(string $provider): OauthSetting
|
||||
{
|
||||
$oauthSetting = OauthSetting::where('provider', $provider)->first();
|
||||
if (! $oauthSetting || ! $oauthSetting->enabled || ! $oauthSetting->couldBeEnabled()) {
|
||||
throw new HttpException(403, 'OAuth provider is not enabled');
|
||||
}
|
||||
|
||||
return $oauthSetting;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ 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\InvokeDeferredCallbacks;
|
||||
use Illuminate\Foundation\Http\Middleware\ValidatePostSize;
|
||||
use Illuminate\Http\Middleware\HandleCors;
|
||||
use Illuminate\Http\Middleware\SetCacheHeaders;
|
||||
@@ -59,6 +60,7 @@ class Kernel extends HttpKernel
|
||||
ValidatePostSize::class,
|
||||
TrimStrings::class,
|
||||
ConvertEmptyStringsToNull::class,
|
||||
InvokeDeferredCallbacks::class,
|
||||
|
||||
];
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\ExecuteRemoteCommand;
|
||||
@@ -147,6 +148,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private $env_args;
|
||||
|
||||
/** @var array<string, string>|null */
|
||||
private ?array $remote_secrets_cache = null;
|
||||
|
||||
private $env_nixpacks_args;
|
||||
|
||||
private $env_railpack_args;
|
||||
@@ -269,7 +273,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->configuration_dir = application_configuration_dir()."/{$this->application->uuid}";
|
||||
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||
|
||||
$this->container_name = $this->resolveContainerName();
|
||||
$this->container_name = generateApplicationContainerName($this->application, $this->pull_request_id);
|
||||
|
||||
$this->saved_outputs = collect();
|
||||
|
||||
@@ -335,7 +339,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($containerName === 'coolify-proxy') {
|
||||
continue;
|
||||
}
|
||||
if (preg_match('/-(\d{12})/', $containerName)) {
|
||||
if (isGeneratedContainerName($containerName)) {
|
||||
continue;
|
||||
}
|
||||
$containerIp = data_get($container, 'IPv4Address');
|
||||
@@ -1309,6 +1313,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
return true;
|
||||
}
|
||||
if ($this->has_remote_buildtime_secret_references()) {
|
||||
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
|
||||
|
||||
return false;
|
||||
}
|
||||
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
|
||||
if (! $configurationDiff->requiresBuild()) {
|
||||
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
@@ -1336,6 +1345,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return false;
|
||||
}
|
||||
|
||||
private function has_remote_buildtime_secret_references(): bool
|
||||
{
|
||||
$environmentVariables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()
|
||||
: $this->application->environment_variables_preview();
|
||||
|
||||
return $environmentVariables
|
||||
->where('is_buildtime', true)
|
||||
->get(['value'])
|
||||
->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
|
||||
}
|
||||
|
||||
private function check_image_locally_or_remotely()
|
||||
{
|
||||
$this->execute_remote_command([
|
||||
@@ -1357,6 +1378,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the secrets from the application's secret manager source. Values
|
||||
* live only in memory during the deployment and in the generated .env on
|
||||
* the server — they are never persisted in the Coolify database. Fetched
|
||||
* lazily (only when a variable references a secret), once per deployment.
|
||||
* A fetch failure fails the deployment.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function remote_secrets(): array
|
||||
{
|
||||
if ($this->remote_secrets_cache !== null) {
|
||||
return $this->remote_secrets_cache;
|
||||
}
|
||||
|
||||
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
|
||||
|
||||
if (! $link) {
|
||||
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
|
||||
}
|
||||
|
||||
$provider = $link->integrationToken->providerName();
|
||||
$tokenName = $link->integrationToken->name;
|
||||
|
||||
try {
|
||||
$secrets = $link->fetchSecrets();
|
||||
} catch (Throwable $e) {
|
||||
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
|
||||
|
||||
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
|
||||
|
||||
return $this->remote_secrets_cache = $secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace {{vault.KEY}} references with values from the configured secret
|
||||
* manager source. Missing keys fail the deployment with a
|
||||
* list — changing the source never re-checks references, so this is the
|
||||
* moment problems surface.
|
||||
*/
|
||||
private function substitute_remote_secrets(string $value, string $envKey): string
|
||||
{
|
||||
$secrets = $this->remote_secrets();
|
||||
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
|
||||
|
||||
if ($missing !== []) {
|
||||
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
|
||||
$this->application_deployment_queue->addLogEntry($message, 'stderr');
|
||||
|
||||
throw new DeploymentException($message.' Check the secret manager source of this application.');
|
||||
}
|
||||
|
||||
return RemoteSecretReferences::substitute($value, $secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve shared variables, then secret references, in a raw variable value.
|
||||
*/
|
||||
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
|
||||
{
|
||||
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
|
||||
|
||||
return $this->substitute_remote_secrets($value ?? '', $env->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a runtime variable to its dotenv representation. Values with
|
||||
* secret references are substituted and written as literals.
|
||||
*/
|
||||
private function resolve_environment_variable(EnvironmentVariable $env): ?string
|
||||
{
|
||||
if (! RemoteSecretReferences::containsReference($env->value)) {
|
||||
return $env->getResolvedValueWithServer($this->mainServer);
|
||||
}
|
||||
|
||||
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a remote secret value for the runtime .env file (dotenv syntax read
|
||||
* by docker compose). Values are treated as literals — no interpolation.
|
||||
*/
|
||||
private function format_remote_secret_value(string $value): string
|
||||
{
|
||||
if (! str_contains($value, "'")) {
|
||||
return "'".$value."'";
|
||||
}
|
||||
|
||||
// Fall back to double quotes; $$ escapes compose interpolation.
|
||||
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
|
||||
}
|
||||
|
||||
private function generate_runtime_environment_variables()
|
||||
{
|
||||
$envs = collect([]);
|
||||
@@ -1425,7 +1541,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
@@ -1492,7 +1608,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
|
||||
// Fall back to production env vars for keys not overridden by preview vars,
|
||||
@@ -1506,7 +1622,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $env->is_runtime && ! in_array($env->key, $previewKeys);
|
||||
});
|
||||
foreach ($fallback_production_vars as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1614,6 +1730,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1632,6 +1749,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
$this->server = $this->build_server;
|
||||
@@ -1639,6 +1757,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1765,6 +1884,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RemoteSecretReferences::containsReference($env->value)) {
|
||||
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
@@ -1820,6 +1945,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RemoteSecretReferences::containsReference($env->value)) {
|
||||
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
@@ -1879,6 +2010,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function validatedBuildtimeEnvironmentVariableKey(string $key, string $origin): string
|
||||
{
|
||||
|
||||
try {
|
||||
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
|
||||
throw new \InvalidArgumentException('Invalid build-time environment variable key.');
|
||||
@@ -2072,7 +2204,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->write_deployment_configurations();
|
||||
$this->server = $this->mainServer;
|
||||
}
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty() || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
if (count($this->application->ports_mappings_array) > 0 || (bool) $this->application->settings->is_consistent_container_name_enabled || $this->pull_request_id !== 0 || str($this->application->custom_docker_run_options)->contains('--ip') || str($this->application->custom_docker_run_options)->contains('--ip6')) {
|
||||
$this->application_deployment_queue->addLogEntry('----------------------------------------');
|
||||
if (count($this->application->ports_mappings_array) > 0) {
|
||||
$this->application_deployment_queue->addLogEntry('Application has ports mapped to the host system, rolling update is not supported.');
|
||||
@@ -2080,7 +2212,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled) {
|
||||
$this->application_deployment_queue->addLogEntry('Consistent container name feature enabled, rolling update is not supported.');
|
||||
}
|
||||
if (str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
if ((bool) $this->application->settings->is_consistent_container_name_enabled && str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
$this->application_deployment_queue->addLogEntry('Custom internal name is set, rolling update is not supported.');
|
||||
}
|
||||
if ($this->pull_request_id !== 0) {
|
||||
@@ -2106,19 +2238,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveContainerName(): string
|
||||
{
|
||||
if (str($this->application->settings->custom_internal_name)->isEmpty()) {
|
||||
return generateApplicationContainerName($this->application, $this->pull_request_id);
|
||||
}
|
||||
|
||||
if ($this->pull_request_id === 0) {
|
||||
return $this->application->settings->custom_internal_name;
|
||||
}
|
||||
|
||||
return addPreviewDeploymentSuffix($this->application->settings->custom_internal_name, $this->pull_request_id);
|
||||
}
|
||||
|
||||
private function health_check()
|
||||
{
|
||||
try {
|
||||
@@ -2789,6 +2908,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
|
||||
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
|
||||
|
||||
return $resolved === '' ? null : $resolved;
|
||||
}
|
||||
|
||||
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
|
||||
if (is_null($resolvedValue) || $resolvedValue === '') {
|
||||
return null;
|
||||
@@ -3332,7 +3457,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
@@ -3348,7 +3475,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
@@ -4169,7 +4298,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
try {
|
||||
$this->application_deployment_queue->addLogEntry('Removing old containers.');
|
||||
if ($this->newVersionIsHealthy || $force) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled || str($this->application->settings->custom_internal_name)->isNotEmpty()) {
|
||||
if ($this->application->settings->is_consistent_container_name_enabled) {
|
||||
$containers = getCurrentApplicationContainerStatus($this->server, $this->application->id, $this->pull_request_id);
|
||||
$this->containerNamesToRemove($containers)->each(function (string $containerName) {
|
||||
$this->graceful_shutdown_container($containerName);
|
||||
@@ -4442,7 +4571,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
} else {
|
||||
$secrets_string = $variables
|
||||
->map(function ($env) {
|
||||
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
|
||||
return "{$env->key}={$this->resolve_environment_variable($env)}";
|
||||
})
|
||||
->sort()
|
||||
->implode('|');
|
||||
@@ -4508,7 +4637,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4530,7 +4659,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4544,6 +4673,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
}
|
||||
|
||||
if ($argsToInsert->isNotEmpty()) {
|
||||
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
|
||||
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
|
||||
});
|
||||
$secretsHash = $this->generate_secrets_hash($environmentVariables);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
|
||||
}
|
||||
|
||||
// Development logging to show what ARGs are being injected
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
@@ -4565,11 +4702,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
||||
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
|
||||
});
|
||||
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||
}
|
||||
|
||||
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
||||
@@ -4578,11 +4710,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
'skip_command_log' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\DnsRecordConfigurationFinished;
|
||||
use App\Models\DnsProviderZone;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class ConfigureDnsRecordJob implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(
|
||||
public int $teamId,
|
||||
public int $zoneId,
|
||||
public ?string $resourceType,
|
||||
public int|string|null $resourceId,
|
||||
public string $hostname,
|
||||
public string $content,
|
||||
) {}
|
||||
|
||||
public function handle(CloudflareDnsProvider $provider): void
|
||||
{
|
||||
$zone = DnsProviderZone::query()
|
||||
->with('integrationToken')
|
||||
->whereKey($this->zoneId)
|
||||
->whereHas('integrationToken', fn ($query) => $query->where('team_id', $this->teamId))
|
||||
->firstOrFail();
|
||||
|
||||
try {
|
||||
$provider->createRecord($zone, $this->hostname, $this->content, $this->resource());
|
||||
|
||||
DnsRecordConfigurationFinished::dispatch(
|
||||
$this->teamId,
|
||||
$this->resourceType,
|
||||
$this->resourceId,
|
||||
$this->hostname,
|
||||
true,
|
||||
$zone->integrationToken->name,
|
||||
"DNS record added for {$this->hostname}.",
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
DnsRecordConfigurationFinished::dispatch(
|
||||
$this->teamId,
|
||||
$this->resourceType,
|
||||
$this->resourceId,
|
||||
$this->hostname,
|
||||
false,
|
||||
$zone->integrationToken->name,
|
||||
$exception->getMessage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
DnsRecordConfigurationFinished::dispatch(
|
||||
$this->teamId,
|
||||
$this->resourceType,
|
||||
$this->resourceId,
|
||||
$this->hostname,
|
||||
false,
|
||||
'',
|
||||
'The DNS zone is no longer available.',
|
||||
);
|
||||
}
|
||||
|
||||
private function resource(): ?Model
|
||||
{
|
||||
$resourceClass = $this->resourceType === null ? null : (Relation::getMorphedModel($this->resourceType) ?? $this->resourceType);
|
||||
if ($resourceClass === null || $this->resourceId === null || ! is_subclass_of($resourceClass, Model::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $resourceClass::query()->find($this->resourceId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Database\StartClickhouse;
|
||||
use App\Actions\Database\StartDragonfly;
|
||||
use App\Actions\Database\StartKeydb;
|
||||
use App\Actions\Database\StartMariadb;
|
||||
use App\Actions\Database\StartMongodb;
|
||||
use App\Actions\Database\StartMysql;
|
||||
use App\Actions\Database\StartPostgresql;
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
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;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Throwable;
|
||||
|
||||
class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
public string $databaseClass,
|
||||
public int $databaseId,
|
||||
public int $teamId,
|
||||
public int $activityId,
|
||||
public ?int $userId,
|
||||
) {
|
||||
$this->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$database = $this->databaseClass::query()->findOrFail($this->databaseId);
|
||||
abort_unless((int) $database->team()->id === $this->teamId, 403);
|
||||
$activity = Activity::query()->findOrFail($this->activityId);
|
||||
|
||||
match ($database->getMorphClass()) {
|
||||
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
|
||||
StandaloneRedis::class => StartRedis::run($database, $activity),
|
||||
StandaloneMongodb::class => StartMongodb::run($database, $activity),
|
||||
StandaloneMysql::class => StartMysql::run($database, $activity),
|
||||
StandaloneMariadb::class => StartMariadb::run($database, $activity),
|
||||
StandaloneKeydb::class => StartKeydb::run($database, $activity),
|
||||
StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
|
||||
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
|
||||
};
|
||||
|
||||
event(new DatabaseStatusChanged($this->userId));
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
try {
|
||||
$activity = Activity::query()->find($this->activityId);
|
||||
if (! $activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'status' => ProcessStatus::ERROR->value,
|
||||
'error' => 'Database start failed.',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$activity->save();
|
||||
} finally {
|
||||
event(new DatabaseStatusChanged($this->userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Listeners;
|
||||
|
||||
use App\Events\DatabaseImportFinished;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class CleanupDatabaseImport implements ShouldQueue
|
||||
{
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [5, 15, 30];
|
||||
|
||||
public function handle(DatabaseImportFinished $event): void
|
||||
{
|
||||
$commands = $this->commands($event->data);
|
||||
$server = Server::query()->find($event->data['serverId'] ?? null);
|
||||
|
||||
if ($server && $commands !== []) {
|
||||
instant_remote_process($commands, $server);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return list<string>
|
||||
*/
|
||||
public function commands(array $data): array
|
||||
{
|
||||
$commands = [];
|
||||
|
||||
if (filled($data['containerName'] ?? null)) {
|
||||
$commands[] = 'docker rm -f '.escapeshellarg($data['containerName']).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
if (isSafeTmpPath($data['serverTmpPath'] ?? null)) {
|
||||
$commands[] = 'rm -f '.escapeshellarg($data['serverTmpPath']).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
if (isSafeTmpPath($data['credentialTmpPath'] ?? null)) {
|
||||
$commands[] = 'rm -f '.escapeshellarg($data['credentialTmpPath']).' 2>/dev/null || true';
|
||||
}
|
||||
|
||||
if (filled($data['container'] ?? null)) {
|
||||
foreach (['containerTmpPath', 'scriptPath'] as $key) {
|
||||
if (isSafeTmpPath($data[$key] ?? null)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($data['container']).' rm -f '.escapeshellarg($data[$key]).' 2>/dev/null || true';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
public function failed(DatabaseImportFinished $event, Throwable $exception): void
|
||||
{
|
||||
Log::error('Database import cleanup failed', [
|
||||
'serverId' => $event->data['serverId'] ?? null,
|
||||
'containerName' => $event->data['containerName'] ?? null,
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\BuildsTrafficChartPayload;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use App\Services\TrafficAnalyticsAggregator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Attributes\Url;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Lazy]
|
||||
class Analytics extends Component
|
||||
{
|
||||
use BuildsTrafficChartPayload;
|
||||
|
||||
public string $chartId = 'global-analytics';
|
||||
|
||||
public ?string $scopedServerUuid = null;
|
||||
|
||||
/** Traffic-enabled servers owned by the current team. */
|
||||
public Collection $servers;
|
||||
|
||||
/** @var array<string, string> uuid => name, for the server filter */
|
||||
public array $serverOptions = [];
|
||||
|
||||
/** @var array<string, string> uuid => name, for the application filter (scoped to the selected server) */
|
||||
public array $appOptions = [];
|
||||
|
||||
/**
|
||||
* Listbox options for the application filter, grouped under project headers so
|
||||
* it's clear which application belongs to which project.
|
||||
*
|
||||
* @var array<int, array{value: string, label: string, header?: bool}>
|
||||
*/
|
||||
public array $appGroupedOptions = [];
|
||||
|
||||
#[Url(as: 'range')]
|
||||
public string $range = '24h';
|
||||
|
||||
#[Url(as: 'server')]
|
||||
public string $serverUuid = '';
|
||||
|
||||
#[Url(as: 'app')]
|
||||
public string $appUuid = '';
|
||||
|
||||
// Realtime refresh; off by default (click "Live" to arm it). Only meaningful on the
|
||||
// 24h range, which matches the 60s Sentinel cache TTL.
|
||||
public bool $live = false;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public bool $latencyApproximate = false;
|
||||
|
||||
public bool $uniquesApproximate = false;
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $topApps = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $topHosts = [];
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $topPaths = [];
|
||||
|
||||
/** @var array<string, array<int, array<string, mixed>>> */
|
||||
public array $breakdowns = [];
|
||||
|
||||
public ?string $attribution = null;
|
||||
|
||||
/**
|
||||
* Per-bucket status-class time series for the stacked area chart, summed across
|
||||
* target servers and sorted by bucket. Empty when no target Sentinel exposes the
|
||||
* series endpoint (older builds), which flips the chart back to the status donut.
|
||||
*
|
||||
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
|
||||
*/
|
||||
public array $series = [];
|
||||
|
||||
public bool $hasSeries = false;
|
||||
|
||||
/**
|
||||
* Servers that could run traffic analytics but have it off — drives the nudge banner.
|
||||
*
|
||||
* @var array<int, array{uuid: string, name: string}>
|
||||
*/
|
||||
public array $eligibleDisabledServers = [];
|
||||
|
||||
public string $nudgeKey = '';
|
||||
|
||||
/**
|
||||
* Upper bound on per-app overviews fetched for the leaderboard, so a server with a huge
|
||||
* number of recorded apps can't reintroduce a per-app round-trip storm. Truncation is
|
||||
* logged (see loadData) rather than silently swallowed.
|
||||
*/
|
||||
private const MAX_LEADERBOARD_APPS = 200;
|
||||
|
||||
/** @var array<int, string> */
|
||||
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
|
||||
|
||||
/**
|
||||
* Per-request cache of app uuid => display metadata, so resolving a name/domain/link
|
||||
* for the leaderboard and path domains hits the DB at most once per app.
|
||||
*
|
||||
* @var array<string, array{name: string, domain: ?string, link: ?string}>
|
||||
*/
|
||||
protected array $appMetaCache = [];
|
||||
|
||||
public function mount(?string $scopedServerUuid = null): void
|
||||
{
|
||||
$allServers = Server::ownedByCurrentTeamCached();
|
||||
|
||||
$this->scopedServerUuid = $scopedServerUuid;
|
||||
|
||||
if ($this->scopedServerUuid !== null) {
|
||||
$server = $allServers->firstWhere('uuid', $this->scopedServerUuid);
|
||||
abort_if($server === null, 404);
|
||||
|
||||
$this->serverUuid = $server->uuid;
|
||||
$this->chartId = 'server-analytics-'.$server->uuid;
|
||||
$this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
|
||||
$this->serverOptions = [$server->uuid => $server->name];
|
||||
$this->eligibleDisabledServers = [];
|
||||
$this->nudgeKey = '';
|
||||
} else {
|
||||
$this->servers = $allServers
|
||||
->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
|
||||
->values();
|
||||
|
||||
$this->serverOptions = $this->servers
|
||||
->mapWithKeys(fn (Server $server) => [$server->uuid => $server->name])
|
||||
->all();
|
||||
|
||||
$eligibleDisabled = $allServers
|
||||
->filter(fn (Server $server) => ! $server->isTrafficAnalyticsEnabled()
|
||||
&& ! $server->isSwarm()
|
||||
&& ! $server->isBuildServer())
|
||||
->values();
|
||||
|
||||
$this->eligibleDisabledServers = $eligibleDisabled
|
||||
->map(fn (Server $server) => ['uuid' => $server->uuid, 'name' => $server->name])
|
||||
->all();
|
||||
$this->nudgeKey = substr(md5($eligibleDisabled->pluck('uuid')->sort()->implode(',')), 0, 12);
|
||||
|
||||
// A bookmarked ?server= may point at a server that is no longer enabled.
|
||||
if ($this->serverUuid !== '' && ! array_key_exists($this->serverUuid, $this->serverOptions)) {
|
||||
$this->serverUuid = '';
|
||||
}
|
||||
}
|
||||
|
||||
$this->refreshAppOptions();
|
||||
|
||||
if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
|
||||
$this->appUuid = '';
|
||||
}
|
||||
|
||||
if ($this->servers->isNotEmpty()) {
|
||||
$this->loadData();
|
||||
}
|
||||
}
|
||||
|
||||
public function setRange(string $range): void
|
||||
{
|
||||
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function toggleLive(): void
|
||||
{
|
||||
if ($this->range !== '24h') {
|
||||
return;
|
||||
}
|
||||
$this->live = ! $this->live;
|
||||
}
|
||||
|
||||
#[On('trafficAnalyticsStateChanged')]
|
||||
public function refreshTrafficAnalyticsState(): void
|
||||
{
|
||||
if ($this->scopedServerUuid === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$server = Server::ownedByCurrentTeam()->whereUuid($this->scopedServerUuid)->firstOrFail();
|
||||
$this->overview = null;
|
||||
$this->servers = $server->isTrafficAnalyticsEnabled() ? collect([$server]) : collect();
|
||||
|
||||
if ($this->servers->isNotEmpty()) {
|
||||
$this->refreshAppOptions();
|
||||
$this->loadData();
|
||||
}
|
||||
}
|
||||
|
||||
public function isLivePollable(): bool
|
||||
{
|
||||
return $this->live && $this->range === '24h';
|
||||
}
|
||||
|
||||
public function updatedServerUuid(): void
|
||||
{
|
||||
// Scope the app options to the newly selected server and drop an app filter
|
||||
// that no longer belongs to it.
|
||||
$this->refreshAppOptions();
|
||||
|
||||
if ($this->appUuid !== '' && ! array_key_exists($this->appUuid, $this->appOptions)) {
|
||||
$this->appUuid = '';
|
||||
}
|
||||
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function updatedAppUuid(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
protected function refreshAppOptions(): void
|
||||
{
|
||||
$enabledUuids = $this->servers->pluck('uuid');
|
||||
|
||||
$apps = Application::ownedByCurrentTeam()->with(['environment.project', 'destination.server'])->get()
|
||||
->filter(function (Application $app) use ($enabledUuids): bool {
|
||||
$serverUuid = $app->destination?->server?->uuid;
|
||||
|
||||
if (! $serverUuid || ! $enabledUuids->contains($serverUuid)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->serverUuid === '' || $serverUuid === $this->serverUuid;
|
||||
});
|
||||
|
||||
// Flat uuid => name map, used to validate a bookmarked ?app= filter.
|
||||
$options = $apps->mapWithKeys(fn (Application $app) => [$app->uuid => $app->name])->all();
|
||||
asort($options);
|
||||
$this->appOptions = $options;
|
||||
|
||||
// Grouped listbox options: a header row per project, then its apps (both alpha-sorted).
|
||||
$grouped = [];
|
||||
$byProject = $apps
|
||||
->groupBy(fn (Application $app) => (string) (data_get($app, 'environment.project.name') ?: 'Ungrouped'))
|
||||
->sortKeys();
|
||||
|
||||
foreach ($byProject as $projectName => $projectApps) {
|
||||
$grouped[] = ['value' => '__group_'.md5($projectName), 'label' => $projectName, 'header' => true];
|
||||
foreach ($projectApps->sortBy('name') as $app) {
|
||||
$grouped[] = ['value' => $app->uuid, 'label' => $app->name];
|
||||
}
|
||||
}
|
||||
|
||||
$this->appGroupedOptions = $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Servers this view should query, honoring the active server/app filters.
|
||||
*/
|
||||
protected function targetServers(): Collection
|
||||
{
|
||||
if ($this->scopedServerUuid !== null) {
|
||||
return $this->servers;
|
||||
}
|
||||
|
||||
if ($this->appUuid !== '') {
|
||||
$server = Application::ownedByCurrentTeam()->whereUuid($this->appUuid)->first()
|
||||
?->destination?->server;
|
||||
|
||||
return $server && $this->servers->contains(fn (Server $s) => $s->uuid === $server->uuid)
|
||||
? collect([$server])
|
||||
: collect();
|
||||
}
|
||||
|
||||
if ($this->serverUuid !== '') {
|
||||
return $this->servers->filter(fn (Server $s) => $s->uuid === $this->serverUuid)->values();
|
||||
}
|
||||
|
||||
return $this->servers;
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
if ($this->servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$from, $to] = $this->window();
|
||||
$appKey = $this->appUuid !== '' ? $this->appUuid : null;
|
||||
$servers = $this->targetServers();
|
||||
|
||||
$overviews = [];
|
||||
$appRows = [];
|
||||
$pathTotals = [];
|
||||
$breakdownTotals = array_fill_keys($this->breakdownDimensions, []);
|
||||
$seriesByBucket = [];
|
||||
$attribution = null;
|
||||
|
||||
foreach ($servers as $server) {
|
||||
try {
|
||||
$client = $this->trafficClient($server);
|
||||
|
||||
// Warm every server-wide endpoint in one docker exec instead of ~15 serial
|
||||
// SSH round-trips; the per-call methods below then read from cache.
|
||||
$leaderboardUuids = $client->prefetchServerWide($appKey, $from, $to, $this->breakdownDimensions, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
|
||||
|
||||
// Per-application leaderboard only makes sense when not already filtered to one app.
|
||||
if ($appKey === null && $leaderboardUuids !== []) {
|
||||
if (count($leaderboardUuids) > self::MAX_LEADERBOARD_APPS) {
|
||||
Log::warning('Traffic analytics leaderboard truncated', [
|
||||
'server' => $server->uuid,
|
||||
'total' => count($leaderboardUuids),
|
||||
'shown' => self::MAX_LEADERBOARD_APPS,
|
||||
]);
|
||||
$leaderboardUuids = array_slice($leaderboardUuids, 0, self::MAX_LEADERBOARD_APPS);
|
||||
}
|
||||
// Warm the leaderboard's per-app overviews in a second batched exec.
|
||||
$client->prefetchAppOverviews($leaderboardUuids, $from, $to);
|
||||
}
|
||||
|
||||
$overviews[] = $client->overview($appKey, $from, $to);
|
||||
|
||||
if ($appKey === null) {
|
||||
foreach ($leaderboardUuids as $uuid) {
|
||||
$appOverview = $client->overview($uuid, $from, $to)->toArray();
|
||||
$meta = $this->appMeta($uuid);
|
||||
|
||||
$appRows[] = [
|
||||
'uuid' => $uuid,
|
||||
'name' => $meta['name'],
|
||||
'domain' => $meta['domain'],
|
||||
'link' => $meta['link'],
|
||||
'requests' => (int) ($appOverview['requests'] ?? 0),
|
||||
'bandwidth' => (int) ($appOverview['bytesIn'] ?? 0) + (int) ($appOverview['bytesOut'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($client->paths($appKey, $from, $to, 50) as $path) {
|
||||
$data = $path->toArray();
|
||||
$pathStr = (string) ($data['path'] ?? '');
|
||||
// Prefer the per-path app from Sentinel; fall back to the active app filter
|
||||
// (older Sentinel omits `app`, but a filtered view still knows the app).
|
||||
$appId = (string) ($data['app'] ?? '');
|
||||
$resolveId = $appId !== '' ? $appId : ($appKey ?? '');
|
||||
// Key by (app, path) so the same path under two apps stays two rows, each
|
||||
// carrying its own domain.
|
||||
$key = $resolveId."\n".$pathStr;
|
||||
$domain = $resolveId !== '' ? ($this->appMeta($resolveId)['domain'] ?? null) : null;
|
||||
|
||||
$pathTotals[$key] ??= ['path' => $pathStr, 'domain' => $domain, 'requests' => 0, 'bytesOut' => 0, 's4xx' => 0, 's5xx' => 0, 'p95' => 0.0];
|
||||
$pathTotals[$key]['requests'] += (int) ($data['requests'] ?? 0);
|
||||
$pathTotals[$key]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
||||
$pathTotals[$key]['s4xx'] += (int) ($data['s4xx'] ?? 0);
|
||||
$pathTotals[$key]['s5xx'] += (int) ($data['s5xx'] ?? 0);
|
||||
$pathTotals[$key]['p95'] = max($pathTotals[$key]['p95'], (float) ($data['p95'] ?? 0));
|
||||
}
|
||||
|
||||
foreach ($this->breakdownDimensions as $dimension) {
|
||||
foreach ($client->breakdown($appKey, $dimension, $from, $to, 50) as $row) {
|
||||
$data = $row->toArray();
|
||||
$value = (string) ($data['value'] ?? '');
|
||||
|
||||
$breakdownTotals[$dimension][$value] ??= ['value' => $value, 'requests' => 0, 'bytesOut' => 0];
|
||||
$breakdownTotals[$dimension][$value]['requests'] += (int) ($data['requests'] ?? 0);
|
||||
$breakdownTotals[$dimension][$value]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
$attribution ??= $client->attribution();
|
||||
|
||||
// Per-bucket status series; summed by bucket across servers. Isolated so a
|
||||
// series hiccup (or an older Sentinel lacking the endpoint) never discards a
|
||||
// server's other data — an empty result simply flips the chart to the donut.
|
||||
try {
|
||||
foreach ($client->series($appKey, $this->range) as $bucket) {
|
||||
$data = $bucket->toArray();
|
||||
$ts = (int) ($data['bucket'] ?? 0);
|
||||
|
||||
$seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
|
||||
$seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
|
||||
$seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
|
||||
$seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
||||
// Uniques summed across servers (approximate); p95 takes the worst bucket.
|
||||
$seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
|
||||
$seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Leave this server out of the series; donut fallback covers it.
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Skip unreachable/failed servers so one bad server doesn't break the whole view.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($overviews)) {
|
||||
$this->resetData();
|
||||
// The chart lives under wire:ignore, so it only updates via this event — dispatch
|
||||
// even when cleared so a previously-populated chart flips to its no-data state
|
||||
// instead of keeping stale data.
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = TrafficAnalyticsAggregator::sumOverviews($overviews);
|
||||
$this->overview = $result['overview']->toArray();
|
||||
$this->latencyApproximate = $result['latencyApproximate'];
|
||||
$this->uniquesApproximate = $result['uniquesApproximate'];
|
||||
|
||||
usort($appRows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
|
||||
$this->topApps = array_slice($appRows, 0, 50);
|
||||
|
||||
// Top hosts: fold per-app volume up to the served hostname (an app's primary
|
||||
// domain). Apps without a configured FQDN collapse into one "Unknown host" row.
|
||||
$hostTotals = [];
|
||||
foreach ($appRows as $row) {
|
||||
$host = $row['domain'] ?? '';
|
||||
$hostTotals[$host] ??= ['host' => $host, 'requests' => 0, 'bandwidth' => 0];
|
||||
$hostTotals[$host]['requests'] += (int) $row['requests'];
|
||||
$hostTotals[$host]['bandwidth'] += (int) $row['bandwidth'];
|
||||
}
|
||||
$hosts = array_values($hostTotals);
|
||||
usort($hosts, fn ($a, $b) => $b['requests'] <=> $a['requests']);
|
||||
$this->topHosts = array_slice($hosts, 0, 50);
|
||||
|
||||
$paths = array_values($pathTotals);
|
||||
usort($paths, fn ($a, $b) => $b['requests'] <=> $a['requests']);
|
||||
$this->topPaths = array_slice($paths, 0, 50);
|
||||
|
||||
$breakdowns = [];
|
||||
foreach ($this->breakdownDimensions as $dimension) {
|
||||
$rows = array_values($breakdownTotals[$dimension]);
|
||||
usort($rows, fn ($a, $b) => $b['requests'] <=> $a['requests']);
|
||||
$breakdowns[$dimension] = array_slice($rows, 0, 50);
|
||||
}
|
||||
$this->breakdowns = $breakdowns;
|
||||
|
||||
$this->attribution = $attribution;
|
||||
|
||||
ksort($seriesByBucket);
|
||||
$this->series = array_values($seriesByBucket);
|
||||
$this->hasSeries = $this->series !== [];
|
||||
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the status chart: the stacked-area time series when available,
|
||||
* plus the donut totals as a fallback for older Sentinel builds.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function chartPayload(): array
|
||||
{
|
||||
$device = $this->deviceChartData();
|
||||
$overview = $this->overview ?? [];
|
||||
|
||||
return [
|
||||
'hasSeries' => $this->hasSeries,
|
||||
'range' => $this->range,
|
||||
'seriesData' => [
|
||||
$overview['s2xx'] ?? 0,
|
||||
$overview['s3xx'] ?? 0,
|
||||
$overview['s4xx'] ?? 0,
|
||||
$overview['s5xx'] ?? 0,
|
||||
],
|
||||
'timeSeries' => [
|
||||
'categories' => array_column($this->series, 'bucket'),
|
||||
'requests' => $this->requestsSpark(),
|
||||
's2xx' => array_column($this->series, 's2xx'),
|
||||
's3xx' => array_column($this->series, 's3xx'),
|
||||
's4xx' => array_column($this->series, 's4xx'),
|
||||
's5xx' => array_column($this->series, 's5xx'),
|
||||
],
|
||||
'requestsSpark' => $this->requestsSpark(),
|
||||
'sparkCategories' => array_column($this->series, 'bucket'),
|
||||
'errorsSpark' => $this->errorsSpark(),
|
||||
'bandwidthSpark' => $this->bandwidthSpark(),
|
||||
'uniquesSpark' => $this->uniquesSpark(),
|
||||
'latencySpark' => $this->latencySpark(),
|
||||
'geo' => $this->geoMarkers(),
|
||||
'deviceLabels' => $device['labels'],
|
||||
'deviceSeries' => $device['series'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function resetData(): void
|
||||
{
|
||||
$this->overview = null;
|
||||
$this->latencyApproximate = false;
|
||||
$this->uniquesApproximate = false;
|
||||
$this->topApps = [];
|
||||
$this->topHosts = [];
|
||||
$this->topPaths = [];
|
||||
$this->breakdowns = [];
|
||||
$this->attribution = null;
|
||||
$this->series = [];
|
||||
$this->hasSeries = false;
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function bandwidthBytes(): int
|
||||
{
|
||||
if (! $this->overview) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
|
||||
}
|
||||
|
||||
protected function trafficClient(Server $server): SentinelTrafficClient
|
||||
{
|
||||
return app(SentinelTrafficClient::class, ['server' => $server]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an app uuid to its display name, primary domain, and analytics-page link,
|
||||
* memoized per request. Returns the uuid as the name for apps not owned by the team
|
||||
* so a Sentinel-reported uuid never discloses another team's application name.
|
||||
*
|
||||
* @return array{name: string, domain: ?string, link: ?string}
|
||||
*/
|
||||
protected function appMeta(string $uuid): array
|
||||
{
|
||||
if (isset($this->appMetaCache[$uuid])) {
|
||||
return $this->appMetaCache[$uuid];
|
||||
}
|
||||
|
||||
$app = Application::ownedByCurrentTeam()->with('environment.project')->whereUuid($uuid)->first();
|
||||
|
||||
$domain = null;
|
||||
if ($app) {
|
||||
$first = collect($app->fqdns)->first();
|
||||
$domain = $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
|
||||
}
|
||||
|
||||
$link = null;
|
||||
if ($app && data_get($app, 'environment.project.uuid')) {
|
||||
$link = route('project.application.analytics', [
|
||||
'project_uuid' => $app->environment->project->uuid,
|
||||
'environment_uuid' => $app->environment->uuid,
|
||||
'application_uuid' => $app->uuid,
|
||||
]);
|
||||
}
|
||||
|
||||
return $this->appMetaCache[$uuid] = [
|
||||
'name' => $app?->name ?? $uuid,
|
||||
'domain' => $domain,
|
||||
'link' => $link,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function window(): array
|
||||
{
|
||||
$to = now();
|
||||
$from = match ($this->range) {
|
||||
'7d' => now()->subDays(7),
|
||||
'30d' => now()->subDays(30),
|
||||
default => now()->subDay(),
|
||||
};
|
||||
|
||||
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
|
||||
}
|
||||
|
||||
public function placeholder(array $params = []): View
|
||||
{
|
||||
$scopedServerUuid = $params['scopedServerUuid'] ?? null;
|
||||
$hideSkeleton = false;
|
||||
|
||||
if (is_string($scopedServerUuid)) {
|
||||
$server = Server::ownedByCurrentTeamCached()->firstWhere('uuid', $scopedServerUuid);
|
||||
$hideSkeleton = $server !== null && ! $server->isTrafficAnalyticsEnabled();
|
||||
}
|
||||
|
||||
// Rendered instantly; the Sentinel round-trips run in the deferred lazy-load request.
|
||||
return view('livewire.analytics-placeholder', compact('hideSkeleton'));
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.analytics');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
/**
|
||||
* Shared derivations for the traffic chart payload: per-bucket sparkline series,
|
||||
* the device-donut labels/series, and the globe's per-country marker data. Consumed
|
||||
* by both the global and per-application analytics components, which expose
|
||||
* `$series` (status buckets) and `$breakdowns` (dimension rows).
|
||||
*/
|
||||
trait BuildsTrafficChartPayload
|
||||
{
|
||||
/**
|
||||
* Per-bucket total requests, for the Requests spark. Derived from the status-class
|
||||
* counts (every request carries a status class), so it always matches real traffic
|
||||
* regardless of whether Sentinel populates the explicit per-bucket `requests` field.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function requestsSpark(): array
|
||||
{
|
||||
return array_map(
|
||||
fn ($b) => (int) ($b['s2xx'] ?? 0) + (int) ($b['s3xx'] ?? 0) + (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
|
||||
$this->series,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether there is plottable request-over-time data for the Requests chart. False when
|
||||
* Sentinel returned no series buckets (older builds) or every bucket is empty (no traffic
|
||||
* in the range), so the views can render a no-data state instead of a blank chart.
|
||||
*/
|
||||
public function hasRequestSeries(): bool
|
||||
{
|
||||
return array_sum($this->requestsSpark()) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket error requests (4xx + 5xx), for the Error-rate spark.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function errorsSpark(): array
|
||||
{
|
||||
return array_map(
|
||||
fn ($b) => (int) ($b['s4xx'] ?? 0) + (int) ($b['s5xx'] ?? 0),
|
||||
$this->series,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket bandwidth (bytes in + out), for the Bandwidth spark. Empty for
|
||||
* older Sentinel builds that don't emit per-bucket byte counts.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function bandwidthSpark(): array
|
||||
{
|
||||
return array_map(
|
||||
fn ($b) => (int) ($b['bytesIn'] ?? 0) + (int) ($b['bytesOut'] ?? 0),
|
||||
$this->series,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket unique visitors, for the Visitors spark.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
public function uniquesSpark(): array
|
||||
{
|
||||
return array_map(fn ($b) => (int) ($b['uniqueVisitors'] ?? 0), $this->series);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-bucket p95 latency (ms), for the Latency spark.
|
||||
*
|
||||
* @return array<int, float>
|
||||
*/
|
||||
public function latencySpark(): array
|
||||
{
|
||||
return array_map(fn ($b) => round((float) ($b['p95'] ?? 0), 1), $this->series);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-country marker data for the globe: [{code, requests}] over known ISO-A2 rows.
|
||||
*
|
||||
* @return array<int, array{code: string, requests: int}>
|
||||
*/
|
||||
protected function geoMarkers(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (($this->breakdowns['country'] ?? []) as $row) {
|
||||
$code = strtoupper((string) ($row['value'] ?? ''));
|
||||
$requests = (int) ($row['requests'] ?? 0);
|
||||
if (preg_match('/^[A-Z]{2}$/', $code) && $requests > 0) {
|
||||
$out[] = ['code' => $code, 'requests' => $requests];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device-donut data. Raw Sentinel device values are folded into friendly labels
|
||||
* (pc → Desktop, smartphone → Mobile, …) and summed, then sorted by volume.
|
||||
*
|
||||
* @return array{labels: array<int, string>, series: array<int, int>}
|
||||
*/
|
||||
public function deviceChartData(): array
|
||||
{
|
||||
$totals = [];
|
||||
foreach (($this->breakdowns['device'] ?? []) as $row) {
|
||||
$value = (string) ($row['value'] ?? '');
|
||||
$label = $value === '__other__' ? 'Other' : deviceLabel($value);
|
||||
$totals[$label] = ($totals[$label] ?? 0) + (int) ($row['requests'] ?? 0);
|
||||
}
|
||||
arsort($totals);
|
||||
|
||||
return [
|
||||
'labels' => array_keys($totals),
|
||||
'series' => array_map('intval', array_values($totals)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Concerns;
|
||||
|
||||
use App\Exceptions\DnsRecordConflictException;
|
||||
use App\Jobs\ConfigureDnsRecordJob;
|
||||
use App\Models\DnsProviderZone;
|
||||
use App\Models\ManagedDnsRecord;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait InteractsWithDnsProviders
|
||||
{
|
||||
public bool $showDnsProviderModal = false;
|
||||
|
||||
public array $dnsProviderProposals = [];
|
||||
|
||||
public array $dnsProviderConflicts = [];
|
||||
|
||||
public bool $deleteManagedDns = true;
|
||||
|
||||
public function openDnsProviderModal(): void
|
||||
{
|
||||
$this->authorizeDnsProviderChange();
|
||||
$this->loadDnsProviderProposals();
|
||||
if ($this->dnsProviderProposals === []) {
|
||||
$this->dispatch('error', 'No connected DNS provider can manage the configured domains.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->showDnsProviderModal = true;
|
||||
}
|
||||
|
||||
public function closeDnsProviderModal(): void
|
||||
{
|
||||
$this->showDnsProviderModal = false;
|
||||
}
|
||||
|
||||
public function createManagedDnsRecord(string $hostname, int $zoneId, ?string $content = null): void
|
||||
{
|
||||
$this->authorizeDnsProviderChange();
|
||||
$cloudflare = app(CloudflareDnsProvider::class);
|
||||
$zone = $this->findTeamZone($zoneId);
|
||||
$content ??= $this->serverIp;
|
||||
if ($zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
|
||||
$this->dispatch('error', 'No connected DNS provider or public server IP is available for this domain.');
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$cloudflare->createRecord($zone, $hostname, $content, $this->dnsResourceForHostname($hostname));
|
||||
$this->markDnsManaged($hostname, $zone->integrationToken->name);
|
||||
$this->dispatch('success', "DNS record created for {$hostname}.");
|
||||
$this->loadDnsProviderProposals();
|
||||
} catch (DnsRecordConflictException $e) {
|
||||
$this->dnsProviderConflicts[$hostname.'|'.$zoneId] = [
|
||||
'record_id' => $e->providerRecordId, 'current' => $e->currentValue, 'proposed' => $e->proposedValue,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, string> $urls */
|
||||
protected function hasDnsProviderForUrls(array $urls): bool
|
||||
{
|
||||
$provider = app(CloudflareDnsProvider::class);
|
||||
|
||||
return collect($urls)->contains(function (string $url) use ($provider): bool {
|
||||
$hostname = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
return is_string($hostname) && $provider->findZones(currentTeam()->id, $hostname)->isNotEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<int, string> $urls */
|
||||
protected function configureDnsAfterDomainAdd(array $urls): bool
|
||||
{
|
||||
$hostnames = collect($urls)->map(fn (string $url) => parse_url($url, PHP_URL_HOST))
|
||||
->filter(fn ($hostname) => is_string($hostname))->map(fn (string $hostname) => strtolower($hostname))
|
||||
->unique()->values()->all();
|
||||
$this->loadDnsProviderProposals($hostnames);
|
||||
if ($this->dnsProviderProposals === []) {
|
||||
return false;
|
||||
}
|
||||
if (blank($this->serverIp) || filter_var($this->serverIp, FILTER_VALIDATE_IP) === false) {
|
||||
return false;
|
||||
}
|
||||
$this->markDnsPending($hostnames);
|
||||
|
||||
$proposalsByHostname = collect($this->dnsProviderProposals)->groupBy('hostname');
|
||||
$canConfigureAutomatically = $proposalsByHostname->every(function ($proposals): bool {
|
||||
if ($proposals->count() !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$zone = $this->findTeamZone((int) $proposals->first()['zone_id']);
|
||||
|
||||
return $zone?->integrationToken->automaticDnsEnabled() === true;
|
||||
});
|
||||
|
||||
if (! $canConfigureAutomatically) {
|
||||
$this->showDnsProviderModal = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($this->dnsProviderProposals as $proposal) {
|
||||
$zone = $this->findTeamZone((int) $proposal['zone_id']);
|
||||
if ($zone === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$resource = $this->dnsResourceForHostname($proposal['hostname']);
|
||||
ConfigureDnsRecordJob::dispatch(
|
||||
currentTeam()->id,
|
||||
$zone->id,
|
||||
$resource?->getMorphClass(),
|
||||
$resource?->getKey(),
|
||||
$proposal['hostname'],
|
||||
$this->serverIp,
|
||||
);
|
||||
$this->dispatch('info', "Adding DNS record for {$proposal['hostname']}.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function openManualDnsRecords(): void
|
||||
{
|
||||
$this->authorizeDnsProviderChange();
|
||||
$this->loadDnsProviderProposals();
|
||||
$this->dispatch('open-dns-records-modal');
|
||||
}
|
||||
|
||||
public function replaceManagedDnsRecord(string $hostname, int $zoneId, string $password = ''): void
|
||||
{
|
||||
$this->authorizeDnsProviderChange();
|
||||
$key = $hostname.'|'.$zoneId;
|
||||
$conflict = $this->dnsProviderConflicts[$key] ?? null;
|
||||
$zone = $this->findTeamZone($zoneId);
|
||||
$content = $this->serverIp;
|
||||
if ($conflict === null || $zone === null || blank($content) || filter_var($content, FILTER_VALIDATE_IP) === false) {
|
||||
$this->dispatch('error', 'The DNS conflict is no longer available. Check the record again.');
|
||||
|
||||
return;
|
||||
}
|
||||
try {
|
||||
app(CloudflareDnsProvider::class)->replaceRecord(
|
||||
$zone,
|
||||
(string) ($conflict['record_id'] ?? ''),
|
||||
$hostname,
|
||||
$content,
|
||||
$this->dnsResourceForHostname($hostname),
|
||||
(string) ($conflict['current'] ?? ''),
|
||||
);
|
||||
unset($this->dnsProviderConflicts[$key]);
|
||||
$this->dispatch('success', "DNS record replaced for {$hostname}.");
|
||||
$this->loadDnsProviderProposals();
|
||||
} catch (\Throwable $e) {
|
||||
unset($this->dnsProviderConflicts[$key]);
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected function loadDnsProviderProposals(?array $hostnames = null): void
|
||||
{
|
||||
$provider = app(CloudflareDnsProvider::class);
|
||||
$hostnames ??= $this->allDomainHostnames();
|
||||
$managed = ManagedDnsRecord::query()->where('team_id', currentTeam()->id)->whereIn('name', $hostnames)->pluck('id', 'name');
|
||||
$this->dnsProviderProposals = collect($hostnames)->flatMap(fn (string $hostname) => $provider->findZones(currentTeam()->id, $hostname)
|
||||
->map(fn (DnsProviderZone $zone) => [
|
||||
'hostname' => $hostname, 'zone_id' => $zone->id, 'zone' => $zone->name,
|
||||
'credential' => $zone->integrationToken->name, 'target' => (string) $this->serverIp,
|
||||
'managed' => $managed->has($hostname),
|
||||
])->all())->values()->all();
|
||||
}
|
||||
|
||||
protected function markDnsPending(array $hostnames): void
|
||||
{
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$hostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
|
||||
if (is_string($hostname) && in_array(strtolower($hostname), $hostnames, true)) {
|
||||
$this->domainRows[$index]['dns_status'] = 'pending';
|
||||
$this->domainRows[$index]['dns_message'] = 'A connected DNS provider can create this record.';
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function markDnsManaged(string $hostname, string $credential): void
|
||||
{
|
||||
foreach ($this->domainRows as $index => $row) {
|
||||
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
|
||||
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
|
||||
$this->domainRows[$index]['dns_status'] = 'ok';
|
||||
$this->domainRows[$index]['dns_message'] = "DNS record created through {$credential}.";
|
||||
$this->domainRows[$index]['checked_at'] = now()->toIso8601String();
|
||||
}
|
||||
}
|
||||
$this->persistDomainDnsStatuses();
|
||||
}
|
||||
|
||||
public function dnsRecordConfigurationFinished(array $event): void
|
||||
{
|
||||
$resource = $this->dnsResourceForHostname($event['hostname']);
|
||||
if ($resource === null || $resource->getMorphClass() !== $event['resourceType']
|
||||
|| (string) $resource->getKey() !== (string) $event['resourceId']) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($event['successful']) {
|
||||
$this->markDnsManaged($event['hostname'], $event['credential']);
|
||||
$this->dispatch('success', $event['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('error', "DNS record could not be added for {$event['hostname']}: {$event['message']}");
|
||||
}
|
||||
|
||||
protected function deleteManagedDnsForUrl(string $url): void
|
||||
{
|
||||
$hostname = parse_url($url, PHP_URL_HOST);
|
||||
if (! is_string($hostname)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$resource = $this->dnsResourceForHostname($hostname);
|
||||
if ($resource === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$record = ManagedDnsRecord::query()
|
||||
->where('team_id', currentTeam()->id)
|
||||
->where('name', strtolower($hostname))
|
||||
->where('resource_type', $resource->getMorphClass())
|
||||
->where('resource_id', $resource->getKey())
|
||||
->first();
|
||||
|
||||
if ($record !== null && ! app(CloudflareDnsProvider::class)->deleteRecord($record)) {
|
||||
$this->dispatch('warning', 'The domain was removed, but its DNS record changed externally and was left untouched.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function authorizeDnsProviderChange(): void
|
||||
{
|
||||
$this->authorize('update', property_exists($this, 'application') ? $this->application : $this->service);
|
||||
}
|
||||
|
||||
protected function findTeamZone(int $zoneId): ?DnsProviderZone
|
||||
{
|
||||
return DnsProviderZone::query()->whereKey($zoneId)
|
||||
->whereHas('integrationToken', fn ($query) => $query->where('team_id', currentTeam()->id))->first();
|
||||
}
|
||||
|
||||
abstract protected function persistDomainDnsStatuses(): void;
|
||||
|
||||
abstract protected function dnsResourceForHostname(string $hostname): ?Model;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Dashboard;
|
||||
|
||||
use App\Livewire\Concerns\BuildsTrafficChartPayload;
|
||||
use App\Models\Server;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use App\Services\TrafficAnalyticsAggregator;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Lazy]
|
||||
class TrafficAnalytics extends Component
|
||||
{
|
||||
use BuildsTrafficChartPayload;
|
||||
|
||||
public string $chartId = 'dashboard-traffic';
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public string $range = '24h';
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public bool $latencyApproximate = false;
|
||||
|
||||
public bool $uniquesApproximate = false;
|
||||
|
||||
/**
|
||||
* Per-bucket status-class series, summed across servers; feeds the KPI sparklines.
|
||||
*
|
||||
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
|
||||
*/
|
||||
public array $series = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->servers = Server::ownedByCurrentTeamCached()
|
||||
->filter(fn (Server $server) => $server->isTrafficAnalyticsEnabled())
|
||||
->values();
|
||||
|
||||
if ($this->servers->isNotEmpty()) {
|
||||
$this->loadData();
|
||||
}
|
||||
}
|
||||
|
||||
public function setRange(string $range): void
|
||||
{
|
||||
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
if ($this->servers->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
[$from, $to] = $this->window();
|
||||
|
||||
$overviews = [];
|
||||
$seriesByBucket = [];
|
||||
|
||||
foreach ($this->servers as $server) {
|
||||
try {
|
||||
$client = $this->trafficClient($server);
|
||||
|
||||
$overviews[] = $client->overview(null, $from, $to);
|
||||
|
||||
// Per-bucket status series, summed across servers, for the sparklines.
|
||||
// Isolated so a series hiccup (older Sentinel) never drops a server's overview.
|
||||
try {
|
||||
foreach ($client->series(null, $this->range) as $bucket) {
|
||||
$data = $bucket->toArray();
|
||||
$ts = (int) ($data['bucket'] ?? 0);
|
||||
|
||||
$seriesByBucket[$ts] ??= ['bucket' => $ts, 's2xx' => 0, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0, 'requests' => 0, 'bytesIn' => 0, 'bytesOut' => 0, 'uniqueVisitors' => 0, 'p95' => 0.0];
|
||||
$seriesByBucket[$ts]['s2xx'] += (int) ($data['s2xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s3xx'] += (int) ($data['s3xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s4xx'] += (int) ($data['s4xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['s5xx'] += (int) ($data['s5xx'] ?? 0);
|
||||
$seriesByBucket[$ts]['requests'] += (int) ($data['requests'] ?? 0);
|
||||
$seriesByBucket[$ts]['bytesIn'] += (int) ($data['bytesIn'] ?? 0);
|
||||
$seriesByBucket[$ts]['bytesOut'] += (int) ($data['bytesOut'] ?? 0);
|
||||
$seriesByBucket[$ts]['uniqueVisitors'] += (int) ($data['uniqueVisitors'] ?? 0);
|
||||
$seriesByBucket[$ts]['p95'] = max($seriesByBucket[$ts]['p95'], (float) ($data['p95'] ?? 0));
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Leave this server out of the sparkline series.
|
||||
\Log::debug('Traffic series fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Skip unreachable/failed servers so one bad server doesn't break the whole summary.
|
||||
\Log::debug('Traffic overview fetch failed', ['server' => $server->uuid, 'error' => $e->getMessage()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($overviews)) {
|
||||
// Every server's fetch failed; don't present an all-zero KPI panel as if it were real data.
|
||||
$this->overview = null;
|
||||
$this->latencyApproximate = false;
|
||||
$this->uniquesApproximate = false;
|
||||
$this->series = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$result = TrafficAnalyticsAggregator::sumOverviews($overviews);
|
||||
|
||||
$this->overview = $result['overview']->toArray();
|
||||
$this->latencyApproximate = $result['latencyApproximate'];
|
||||
$this->uniquesApproximate = $result['uniquesApproximate'];
|
||||
|
||||
ksort($seriesByBucket);
|
||||
$this->series = array_values($seriesByBucket);
|
||||
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", [
|
||||
'requestsSpark' => $this->requestsSpark(),
|
||||
'sparkCategories' => array_column($this->series, 'bucket'),
|
||||
'errorsSpark' => $this->errorsSpark(),
|
||||
'bandwidthSpark' => $this->bandwidthSpark(),
|
||||
'uniquesSpark' => $this->uniquesSpark(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function bandwidthBytes(): int
|
||||
{
|
||||
if (! $this->overview) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
|
||||
}
|
||||
|
||||
protected function trafficClient(Server $server): SentinelTrafficClient
|
||||
{
|
||||
return app(SentinelTrafficClient::class, ['server' => $server]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function window(): array
|
||||
{
|
||||
$to = now();
|
||||
$from = match ($this->range) {
|
||||
'7d' => now()->subDays(7),
|
||||
'30d' => now()->subDays(30),
|
||||
default => now()->subDay(),
|
||||
};
|
||||
|
||||
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
|
||||
}
|
||||
|
||||
public function placeholder(): View
|
||||
{
|
||||
// Rendered instantly on the dashboard; Sentinel round-trips run in the deferred request.
|
||||
return view('livewire.dashboard.traffic-analytics-placeholder');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.dashboard.traffic-analytics');
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,30 @@ class Discord extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleDiscordEnabled(): void
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->discordEnabled) {
|
||||
$this->discordEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'discordWebhookUrl' => 'required',
|
||||
], [
|
||||
'discordWebhookUrl.required' => 'Discord Webhook URL is required.',
|
||||
]);
|
||||
$this->discordEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -258,32 +258,59 @@ class Email extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSmtp()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->smtpEnabled = false;
|
||||
$this->saveModel();
|
||||
} else {
|
||||
$this->validateSmtpSettings();
|
||||
$this->smtpEnabled = true;
|
||||
$this->resendEnabled = false;
|
||||
$this->submitSmtp();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleResend()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->resendEnabled) {
|
||||
$this->resendEnabled = false;
|
||||
$this->saveModel();
|
||||
} else {
|
||||
$this->validateResendSettings();
|
||||
$this->resendEnabled = true;
|
||||
$this->smtpEnabled = false;
|
||||
$this->submitResend();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function submitSmtp()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
$this->validateSmtpSettings();
|
||||
|
||||
if ($this->smtpEnabled) {
|
||||
$this->settings->resend_enabled = $this->resendEnabled = false;
|
||||
@@ -315,17 +342,7 @@ class Email extends Component
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
$this->validateResendSettings();
|
||||
if ($this->resendEnabled) {
|
||||
$this->settings->smtp_enabled = $this->smtpEnabled = false;
|
||||
}
|
||||
@@ -342,6 +359,45 @@ class Email extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function validateSmtpSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'smtpEnabled' => 'boolean',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
'smtpHost' => 'required|string',
|
||||
'smtpPort' => 'required|numeric',
|
||||
'smtpEncryption' => 'required|string|in:starttls,tls,none',
|
||||
'smtpUsername' => 'nullable|string',
|
||||
'smtpPassword' => 'nullable|string',
|
||||
'smtpTimeout' => 'nullable|numeric',
|
||||
'smtpEhloDomain' => ['nullable', 'string', new ValidHostname],
|
||||
], [
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
'smtpHost.required' => 'SMTP Host is required.',
|
||||
'smtpPort.required' => 'SMTP Port is required.',
|
||||
'smtpPort.numeric' => 'SMTP Port must be a number.',
|
||||
'smtpEncryption.required' => 'Encryption type is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
private function validateResendSettings(): void
|
||||
{
|
||||
$this->validate([
|
||||
'resendEnabled' => 'boolean',
|
||||
'resendApiKey' => $this->resendEnabled ? 'required|string' : 'nullable|string',
|
||||
'smtpFromAddress' => 'required|email',
|
||||
'smtpFromName' => 'required|string',
|
||||
], [
|
||||
'resendApiKey.required' => 'Resend API Key is required.',
|
||||
'smtpFromAddress.required' => 'From Address is required.',
|
||||
'smtpFromAddress.email' => 'Please enter a valid email address.',
|
||||
'smtpFromName.required' => 'From Name is required.',
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendTestEmail()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -163,6 +163,34 @@ class Pushover extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function togglePushoverEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->pushoverEnabled) {
|
||||
$this->pushoverEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'pushoverUserKey' => 'required',
|
||||
'pushoverApiToken' => 'required',
|
||||
], [
|
||||
'pushoverUserKey.required' => 'Pushover User Key is required.',
|
||||
'pushoverApiToken.required' => 'Pushover API Token is required.',
|
||||
]);
|
||||
$this->pushoverEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -154,6 +154,32 @@ class Slack extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleSlackEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->slackEnabled) {
|
||||
$this->slackEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'slackWebhookUrl' => 'required',
|
||||
], [
|
||||
'slackWebhookUrl.required' => 'Slack Webhook URL is required.',
|
||||
]);
|
||||
$this->slackEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -263,6 +263,34 @@ class Telegram extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleTelegramEnabled(): void
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->telegramEnabled) {
|
||||
$this->telegramEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'telegramToken' => 'required',
|
||||
'telegramChatId' => 'required',
|
||||
], [
|
||||
'telegramToken.required' => 'Telegram Token is required.',
|
||||
'telegramChatId.required' => 'Telegram Chat ID is required.',
|
||||
]);
|
||||
$this->telegramEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
} finally {
|
||||
$this->dispatch('refresh');
|
||||
}
|
||||
}
|
||||
|
||||
public function saveModel()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
@@ -148,6 +148,30 @@ class Webhook extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleWebhookEnabled()
|
||||
{
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
|
||||
if ($this->webhookEnabled) {
|
||||
$this->webhookEnabled = false;
|
||||
} else {
|
||||
$this->validate([
|
||||
'webhookUrl' => 'required',
|
||||
], [
|
||||
'webhookUrl.required' => 'Webhook URL is required.',
|
||||
]);
|
||||
$this->webhookEnabled = true;
|
||||
}
|
||||
|
||||
$this->saveModel();
|
||||
} catch (\Throwable $e) {
|
||||
$this->syncData();
|
||||
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -2,19 +2,15 @@
|
||||
|
||||
namespace App\Livewire\Profile;
|
||||
|
||||
use App\Services\AvatarStorageService;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class Index extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public int $userId;
|
||||
|
||||
public string $email;
|
||||
@@ -36,6 +32,10 @@ class Index extends Component
|
||||
|
||||
public bool $show_verification = false;
|
||||
|
||||
public bool $uses_sso = false;
|
||||
|
||||
public ?string $sso_provider_label = null;
|
||||
|
||||
public $avatar;
|
||||
|
||||
public function uploadAvatar(AvatarStorageService $avatarStorage): bool
|
||||
@@ -75,8 +75,12 @@ class Index extends Component
|
||||
$this->name = Auth::user()->name;
|
||||
$this->email = Auth::user()->email;
|
||||
|
||||
$oauthIdentity = Auth::user()->oauthIdentities()->latest('id')->first();
|
||||
$this->uses_sso = $oauthIdentity !== null;
|
||||
$this->sso_provider_label = $oauthIdentity ? $this->providerLabel($oauthIdentity->provider) : null;
|
||||
|
||||
// Check if there's a pending email change
|
||||
if (Auth::user()->hasEmailChangeRequest()) {
|
||||
if (! $this->uses_sso && Auth::user()->hasEmailChangeRequest()) {
|
||||
$this->new_email = Auth::user()->pending_email;
|
||||
$this->show_verification = true;
|
||||
}
|
||||
@@ -101,6 +105,10 @@ class Index extends Component
|
||||
public function requestEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For self-hosted, check if email is enabled
|
||||
if (! isCloud()) {
|
||||
$settings = instanceSettings();
|
||||
@@ -159,6 +167,10 @@ class Index extends Component
|
||||
public function verifyEmailChange()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->validate([
|
||||
'email_verification_code' => ['required', 'string', 'size:6'],
|
||||
]);
|
||||
@@ -204,7 +216,6 @@ class Index extends Component
|
||||
$this->show_verification = false;
|
||||
|
||||
$this->dispatch('success', 'Email address updated successfully.');
|
||||
$this->dispatch('close-email-change-modal');
|
||||
} else {
|
||||
$this->dispatch('error', 'Failed to update email address.');
|
||||
}
|
||||
@@ -216,6 +227,10 @@ class Index extends Component
|
||||
public function resendVerificationCode()
|
||||
{
|
||||
try {
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if there's a pending request
|
||||
if (! Auth::user()->hasEmailChangeRequest()) {
|
||||
$this->dispatch('error', 'No pending email change request.');
|
||||
@@ -269,6 +284,30 @@ class Index extends Component
|
||||
$this->dispatch('success', 'Email change request cancelled.');
|
||||
}
|
||||
|
||||
public function showEmailChangeForm()
|
||||
{
|
||||
if ($this->rejectSsoEmailChange()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->show_email_change = true;
|
||||
$this->new_email = '';
|
||||
}
|
||||
|
||||
private function rejectSsoEmailChange(): bool
|
||||
{
|
||||
if (! Auth::user()->hasSsoIdentity()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->uses_sso = true;
|
||||
$this->show_email_change = false;
|
||||
$this->show_verification = false;
|
||||
$this->dispatch('error', 'Email addresses managed by SSO cannot be changed in Coolify.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function resetPassword()
|
||||
{
|
||||
try {
|
||||
@@ -299,6 +338,14 @@ class Index extends Component
|
||||
}
|
||||
}
|
||||
|
||||
private function providerLabel(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'oidc' => 'OIDC',
|
||||
default => str($provider)->headline()->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.profile.index');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -69,6 +70,9 @@ class Advanced extends Component
|
||||
#[Validate(['string', 'nullable'])]
|
||||
public ?string $customInternalName = null;
|
||||
|
||||
#[Validate(['string', 'nullable', 'max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH])]
|
||||
public ?string $customContainerNamePrefix = null;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isGzipEnabled = true;
|
||||
|
||||
@@ -111,6 +115,7 @@ class Advanced extends Component
|
||||
$this->application->settings->is_build_server_enabled = $this->isBuildServerEnabled;
|
||||
$this->application->settings->is_consistent_container_name_enabled = $this->isConsistentContainerNameEnabled;
|
||||
$this->application->settings->custom_internal_name = $this->customInternalName;
|
||||
$this->application->settings->custom_container_name_prefix = $this->customContainerNamePrefix;
|
||||
$this->application->settings->is_gzip_enabled = $this->isGzipEnabled;
|
||||
$this->application->settings->is_stripprefix_enabled = $this->isStripprefixEnabled;
|
||||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||
@@ -137,6 +142,7 @@ class Advanced extends Component
|
||||
$this->isBuildServerEnabled = $this->application->settings->is_build_server_enabled;
|
||||
$this->isConsistentContainerNameEnabled = $this->application->settings->is_consistent_container_name_enabled;
|
||||
$this->customInternalName = $this->application->settings->custom_internal_name;
|
||||
$this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
|
||||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||
@@ -258,6 +264,28 @@ class Advanced extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function saveCustomNamePrefix()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->customContainerNamePrefix = str($this->customContainerNamePrefix)->slug()->value() ?: null;
|
||||
|
||||
if ($this->customContainerNamePrefix && ApplicationSetting::isContainerNamePrefixInUse($this->customContainerNamePrefix, $this->application->destination->server, $this->application->id)) {
|
||||
$this->customContainerNamePrefix = $this->application->settings->custom_container_name_prefix;
|
||||
$this->dispatch('error', 'This container name prefix is already in use by another application on this Coolify instance.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->syncData(true);
|
||||
$this->dispatch('success', 'Container name prefix saved.');
|
||||
$this->dispatch('configurationChanged');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveStopGracePeriod()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Livewire\Concerns\BuildsTrafficChartPayload;
|
||||
use App\Models\Application;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Lazy]
|
||||
class Analytics extends Component
|
||||
{
|
||||
use BuildsTrafficChartPayload;
|
||||
|
||||
public Application $application;
|
||||
|
||||
public string $chartId = 'application-analytics';
|
||||
|
||||
public string $range = '24h';
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
// Realtime refresh. Off by default (click "Live" to arm it). Only meaningful on the
|
||||
// 24h range; the 60s cadence matches the SentinelTrafficClient cache TTL and
|
||||
// Sentinel's per-minute rollups. The control is disabled for 7d/30d.
|
||||
public bool $live = false;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public array $topPaths = [];
|
||||
|
||||
/** @var array<string, array<int, array<string, mixed>>> */
|
||||
public array $breakdowns = [];
|
||||
|
||||
public ?string $attribution = null;
|
||||
|
||||
/**
|
||||
* Per-bucket status-class time series for the stacked area chart. Empty when this
|
||||
* app's Sentinel lacks the series endpoint, which flips the chart to the donut.
|
||||
*
|
||||
* @var array<int, array{bucket: int, s2xx: int, s3xx: int, s4xx: int, s5xx: int}>
|
||||
*/
|
||||
public array $series = [];
|
||||
|
||||
public bool $hasSeries = false;
|
||||
|
||||
/** @var array<int, string> */
|
||||
protected array $breakdownDimensions = ['country', 'referer', 'browser', 'os', 'device', 'protocol', 'cache', 'status', 'agent', 'ip', 'useragent'];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->enabled = (bool) $this->application->destination?->server?->isTrafficAnalyticsEnabled();
|
||||
|
||||
if ($this->enabled) {
|
||||
$this->loadData();
|
||||
}
|
||||
}
|
||||
|
||||
public function setRange(string $range): void
|
||||
{
|
||||
$this->range = in_array($range, ['24h', '7d', '30d'], true) ? $range : '24h';
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
public function toggleLive(): void
|
||||
{
|
||||
if ($this->range !== '24h') {
|
||||
return;
|
||||
}
|
||||
$this->live = ! $this->live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Realtime polling is only armed when the user has it on and the range is 24h.
|
||||
*/
|
||||
public function isLivePollable(): bool
|
||||
{
|
||||
return $this->live && $this->range === '24h';
|
||||
}
|
||||
|
||||
public function loadData(): void
|
||||
{
|
||||
if (! $this->enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
[$from, $to] = $this->window();
|
||||
$client = $this->trafficClient();
|
||||
$key = $this->application->uuid;
|
||||
|
||||
// Warm every endpoint for this app in one docker exec instead of ~14 serial
|
||||
// SSH round-trips; the per-call methods below then read from cache.
|
||||
$client->prefetchServerWide($key, $from, $to, $this->breakdownDimensions, $this->range);
|
||||
|
||||
$this->overview = $client->overview($key, $from, $to)->toArray();
|
||||
|
||||
// Every path belongs to this one app, so decorate each row with its domain
|
||||
// for a consistent "domain + path" presentation and an openable live link.
|
||||
$domain = $this->applicationDomain();
|
||||
$this->topPaths = $client->paths($key, $from, $to, 50)
|
||||
->map(fn ($path) => ['domain' => $domain] + $path->toArray())
|
||||
->all();
|
||||
|
||||
$breakdowns = [];
|
||||
foreach ($this->breakdownDimensions as $dimension) {
|
||||
$breakdowns[$dimension] = $client->breakdown($key, $dimension, $from, $to, 50)
|
||||
->map(fn ($row) => $row->toArray())
|
||||
->all();
|
||||
}
|
||||
$this->breakdowns = $breakdowns;
|
||||
|
||||
$this->attribution = $client->attribution();
|
||||
|
||||
// Per-bucket status series; absent on older Sentinel builds (empty → donut fallback).
|
||||
// Isolated so a series hiccup never errors the rest of the widget.
|
||||
try {
|
||||
$this->series = $client->series($key, $this->range)
|
||||
->map(fn ($bucket) => $bucket->toArray())
|
||||
->all();
|
||||
} catch (\Throwable $e) {
|
||||
$this->series = [];
|
||||
}
|
||||
$this->hasSeries = $this->series !== [];
|
||||
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-status", $this->chartPayload());
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for the status chart: the stacked-area time series when available,
|
||||
* plus the donut totals as a fallback for older Sentinel builds.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function chartPayload(): array
|
||||
{
|
||||
$device = $this->deviceChartData();
|
||||
|
||||
return [
|
||||
'hasSeries' => $this->hasSeries,
|
||||
'range' => $this->range,
|
||||
'seriesData' => [
|
||||
$this->overview['s2xx'] ?? 0,
|
||||
$this->overview['s3xx'] ?? 0,
|
||||
$this->overview['s4xx'] ?? 0,
|
||||
$this->overview['s5xx'] ?? 0,
|
||||
],
|
||||
'timeSeries' => [
|
||||
'categories' => array_column($this->series, 'bucket'),
|
||||
'requests' => $this->requestsSpark(),
|
||||
's2xx' => array_column($this->series, 's2xx'),
|
||||
's3xx' => array_column($this->series, 's3xx'),
|
||||
's4xx' => array_column($this->series, 's4xx'),
|
||||
's5xx' => array_column($this->series, 's5xx'),
|
||||
],
|
||||
'requestsSpark' => $this->requestsSpark(),
|
||||
'sparkCategories' => array_column($this->series, 'bucket'),
|
||||
'errorsSpark' => $this->errorsSpark(),
|
||||
'bandwidthSpark' => $this->bandwidthSpark(),
|
||||
'uniquesSpark' => $this->uniquesSpark(),
|
||||
'latencySpark' => $this->latencySpark(),
|
||||
'geo' => $this->geoMarkers(),
|
||||
'deviceLabels' => $device['labels'],
|
||||
'deviceSeries' => $device['series'],
|
||||
];
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function bandwidthBytes(): int
|
||||
{
|
||||
if (! $this->overview) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int) ($this->overview['bytesIn'] ?? 0) + (int) ($this->overview['bytesOut'] ?? 0);
|
||||
}
|
||||
|
||||
protected function trafficClient(): SentinelTrafficClient
|
||||
{
|
||||
return app(SentinelTrafficClient::class, ['server' => $this->application->destination->server]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary domain host for this application (first configured FQDN), or null when
|
||||
* none is set — used to present paths as "domain + path" with an openable link.
|
||||
*/
|
||||
protected function applicationDomain(): ?string
|
||||
{
|
||||
$first = collect($this->application->fqdns)->first();
|
||||
|
||||
return $first ? (parse_url($first, PHP_URL_HOST) ?: null) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: string, 1: string}
|
||||
*/
|
||||
private function window(): array
|
||||
{
|
||||
$to = now();
|
||||
$from = match ($this->range) {
|
||||
'7d' => now()->subDays(7),
|
||||
'30d' => now()->subDays(30),
|
||||
default => now()->subDay(),
|
||||
};
|
||||
|
||||
return [$from->toIso8601ZuluString(), $to->toIso8601ZuluString()];
|
||||
}
|
||||
|
||||
public function placeholder(array $params = []): View
|
||||
{
|
||||
$application = $params['application'] ?? null;
|
||||
|
||||
if ($application instanceof Application && ! $application->destination?->server?->isTrafficAnalyticsEnabled()) {
|
||||
$this->application = $application;
|
||||
$this->enabled = false;
|
||||
|
||||
return view('livewire.project.application.analytics');
|
||||
}
|
||||
|
||||
// Rendered instantly; the Sentinel round-trip runs in the deferred lazy-load request.
|
||||
return view('livewire.project.application.analytics-placeholder');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.analytics');
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,6 @@ class DeploymentNavbar extends Component
|
||||
$this->application_deployment_queue->update([
|
||||
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||
]);
|
||||
|
||||
try {
|
||||
if ($this->application->settings->is_build_server_enabled) {
|
||||
$server = Server::ownedByCurrentTeam()->find($build_server_id);
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Livewire\Project\Application;
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Concerns\InteractsWithDnsProviders;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +22,7 @@ class Domains extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use InteractsWithCloudflareDomainConnect;
|
||||
use InteractsWithDnsProviders;
|
||||
|
||||
protected bool $notifyRedirectUpdate = true;
|
||||
|
||||
@@ -127,6 +130,13 @@ class Domains extends Component
|
||||
'confirmDomainUsage',
|
||||
];
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return array_merge($this->listeners, [
|
||||
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
@@ -1027,8 +1037,14 @@ class Domains extends Component
|
||||
$this->resetAddDomainForm();
|
||||
$this->dispatch('close-modal');
|
||||
$this->refreshDomains();
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$dnsChecks = collect($this->dnsEntriesForUrls($urlsToCheck, $serviceForCheck))
|
||||
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dnsChecks = collect($this->dnsEntriesForUrls($addedUrls, $serviceForCheck))
|
||||
->map(fn (string $url, string $statusKey) => [
|
||||
'status_key' => $statusKey,
|
||||
'url' => $url,
|
||||
@@ -1562,7 +1578,7 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(int $index): void
|
||||
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
@@ -1585,6 +1601,10 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array('deleteManagedDns', $selectedActions, true)) {
|
||||
$this->deleteManagedDnsForUrl($url);
|
||||
}
|
||||
|
||||
if ($this->editingIndex === $index) {
|
||||
$this->cancelEdit();
|
||||
}
|
||||
@@ -1597,7 +1617,7 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
|
||||
@@ -1608,7 +1628,7 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
$this->removeDomain((int) $index, $password, $selectedActions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1619,6 +1639,11 @@ class Domains extends Component
|
||||
return hash('sha256', $row['url'].'|'.($row['service'] ?? ''));
|
||||
}
|
||||
|
||||
protected function dnsResourceForHostname(string $hostname): ?Model
|
||||
{
|
||||
return $this->application;
|
||||
}
|
||||
|
||||
public function generateDomain(?string $serviceName = null): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -156,6 +156,11 @@ class Heading extends Component
|
||||
|
||||
$this->dispatch('info', 'Gracefully stopping application.<br/>It could take a while depending on the application.');
|
||||
StopApplication::dispatch($this->application, false, $this->docker_cleanup);
|
||||
auditLog('ui.application.stopped', [
|
||||
'team_id' => $this->application->team()?->id,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'application_name' => $this->application->name,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -314,6 +314,12 @@ class Previews extends Component
|
||||
ApplicationPreview::where('application_id', $this->application->id)
|
||||
->where('pull_request_id', $pull_request_id)
|
||||
->update(['status' => 'exited']);
|
||||
auditLog('ui.application.preview_stopped', [
|
||||
'team_id' => $this->application->team()?->id,
|
||||
'application_uuid' => $this->application->uuid,
|
||||
'application_name' => $this->application->name,
|
||||
'pull_request_id' => $pull_request_id,
|
||||
]);
|
||||
ServiceStatusChanged::dispatch($this->application->environment->project->team->id);
|
||||
|
||||
GetContainersStatus::run($server);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Application;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Services\SentinelTrafficClient;
|
||||
use Livewire\Attributes\Lazy;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Compact last-24h traffic KPI card for the application General page. Lazy-loaded so
|
||||
* the General page isn't blocked by Sentinel's docker-exec round-trip.
|
||||
*/
|
||||
#[Lazy]
|
||||
class TrafficOverview extends Component
|
||||
{
|
||||
public Application $application;
|
||||
|
||||
public bool $enabled = false;
|
||||
|
||||
public bool $eligible = false;
|
||||
|
||||
public ?string $serverUuid = null;
|
||||
|
||||
public ?array $overview = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
// Runs in the deferred lazy-load request, so the Sentinel fetch never blocks
|
||||
// the initial General-page render.
|
||||
$server = $this->application->destination?->server;
|
||||
$this->serverUuid = $server?->uuid;
|
||||
$this->enabled = (bool) $server?->isTrafficAnalyticsEnabled();
|
||||
$this->eligible = $server ? (! $server->isSwarm() && ! $server->isBuildServer()) : false;
|
||||
|
||||
if ($this->enabled && $server) {
|
||||
try {
|
||||
$client = app(SentinelTrafficClient::class, ['server' => $server]);
|
||||
$this->overview = $client->appOverview($this->application->uuid, '24h')->toArray();
|
||||
} catch (\Throwable $e) {
|
||||
$this->overview = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function hasData(): bool
|
||||
{
|
||||
return $this->overview !== null && (int) ($this->overview['requests'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public function errorRate(): float
|
||||
{
|
||||
if (! $this->overview || (int) ($this->overview['requests'] ?? 0) === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$errors = (int) ($this->overview['s4xx'] ?? 0) + (int) ($this->overview['s5xx'] ?? 0);
|
||||
|
||||
return round(($errors / $this->overview['requests']) * 100, 2);
|
||||
}
|
||||
|
||||
public function placeholder(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<div class="h-24 w-full animate-pulse rounded-xl border border-neutral-200 bg-neutral-50 dark:border-white/[0.08] dark:bg-white/[0.02]"></div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.application.traffic-overview');
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,14 @@ class CloneMe extends Component
|
||||
if (! $selectedDestination) {
|
||||
throw new \Exception('Destination not found.');
|
||||
}
|
||||
auditLog('ui.project.clone_started', [
|
||||
'team_id' => $this->project->team_id,
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'project_name' => $this->project->name,
|
||||
'clone_type' => $type,
|
||||
'new_name' => $this->newName,
|
||||
'destination_uuid' => $selectedDestination->uuid,
|
||||
]);
|
||||
if ($type === 'project') {
|
||||
$foundProject = Project::where('name', $this->newName)->first();
|
||||
if ($foundProject) {
|
||||
|
||||
@@ -212,10 +212,18 @@ class BackupEdit extends Component
|
||||
}
|
||||
}
|
||||
|
||||
$database = $this->backup->database;
|
||||
$backupUuid = $this->backup->uuid;
|
||||
$this->backup->delete();
|
||||
auditLog('ui.database.backup_schedule_deleted', [
|
||||
'team_id' => $database->team()?->id,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'backup_uuid' => $backupUuid,
|
||||
]);
|
||||
|
||||
if ($this->backup->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$serviceDatabase = $this->backup->database;
|
||||
if ($database->getMorphClass() === ServiceDatabase::class) {
|
||||
$serviceDatabase = $database;
|
||||
|
||||
return redirectRoute($this, 'project.service.database.backups', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
@@ -251,9 +259,14 @@ class BackupEdit extends Component
|
||||
}
|
||||
|
||||
DatabaseBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
|
||||
$database = $this->backup->database;
|
||||
auditLog('ui.database.backup_started', [
|
||||
'team_id' => $database->team()?->id,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
|
||||
if ($database instanceof ServiceDatabase) {
|
||||
return redirect()->route('project.service.database.backup.executions', [
|
||||
|
||||
@@ -25,6 +25,13 @@ class BackupNow extends Component
|
||||
}
|
||||
|
||||
DatabaseBackupJob::dispatch($this->backup);
|
||||
$database = $this->backup->database;
|
||||
auditLog('ui.database.backup_started', [
|
||||
'team_id' => $database->team()?->id,
|
||||
'database_uuid' => $database->uuid,
|
||||
'database_name' => $database->name,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -89,6 +89,7 @@ class Heading extends Component
|
||||
|
||||
$this->dispatch('info', 'Gracefully stopping database.');
|
||||
StopDatabase::dispatch($this->database, false, $this->docker_cleanup);
|
||||
$this->auditDatabaseAction('ui.database.stopped');
|
||||
} catch (\Exception $e) {
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
}
|
||||
@@ -100,6 +101,7 @@ class Heading extends Component
|
||||
$this->authorize('manage', $this->database);
|
||||
|
||||
$activity = RestartDatabase::run($this->database);
|
||||
$this->auditDatabaseAction('ui.database.restarted');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
|
||||
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -113,6 +115,7 @@ class Heading extends Component
|
||||
$this->authorize('manage', $this->database);
|
||||
|
||||
$activity = StartDatabase::run($this->database);
|
||||
$this->auditDatabaseAction('ui.database.started');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startdatabase'))");
|
||||
$this->dispatch('activityMonitor', $activity->id, ServiceStatusChanged::class);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -128,4 +131,13 @@ class Heading extends Component
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function auditDatabaseAction(string $event): void
|
||||
{
|
||||
auditLog($event, [
|
||||
'team_id' => $this->database->team()?->id,
|
||||
'database_uuid' => $this->database->uuid,
|
||||
'database_name' => $this->database->name,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Actions\Database\StartDatabaseImport;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -10,12 +11,13 @@ 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;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\DatabaseImport\DatabaseImportCommandBuilder;
|
||||
use App\Support\DatabaseImport\DatabaseImportException;
|
||||
use App\Support\DatabaseImport\DatabaseImportSource;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -158,13 +160,15 @@ class ImportForm extends Component
|
||||
|
||||
public bool $dumpAll = false;
|
||||
|
||||
public bool $replaceExisting = false;
|
||||
|
||||
public string $restoreCommandText = '';
|
||||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
public string $postgresqlRestoreCommand = 'pg_restore --exit-on-error -U $POSTGRES_USER -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
|
||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||
|
||||
@@ -276,13 +280,24 @@ createdb -U ${POSTGRES_USER} ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}
|
||||
EOD;
|
||||
$this->restoreCommandText = $this->postgresqlRestoreCommand.' && (gunzip -cf <temp_backup_file> 2>/dev/null || cat <temp_backup_file>) | psql -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
} else {
|
||||
$this->postgresqlRestoreCommand = 'pg_restore -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
$this->syncPostgresqlRestoreCommand();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public function updatedReplaceExisting(): void
|
||||
{
|
||||
$this->syncPostgresqlRestoreCommand();
|
||||
}
|
||||
|
||||
private function syncPostgresqlRestoreCommand(): void
|
||||
{
|
||||
$replaceExisting = $this->replaceExisting ? ' --clean --if-exists' : '';
|
||||
$this->postgresqlRestoreCommand = 'pg_restore --exit-on-error'.$replaceExisting.' -U ${POSTGRES_USER} -d ${POSTGRES_DB:-${POSTGRES_USER:-postgres}}';
|
||||
}
|
||||
|
||||
public function getContainers()
|
||||
{
|
||||
$this->containers = [];
|
||||
@@ -446,72 +461,25 @@ EOD;
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
$backupFileName = "upload/{$this->resourceUuid}/restore";
|
||||
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
|
||||
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
|
||||
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
|
||||
Storage::delete($backupFileName);
|
||||
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return true;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
$this->importCommands[] = "chmod +x {$scriptPath}";
|
||||
$this->importCommands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c '{$scriptPath}'";
|
||||
$this->importCommands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
if (! empty($this->importCommands)) {
|
||||
$activity = remote_process($this->importCommands, $this->server, ignore_errors: true, callEventOnFinish: 'RestoreJobFinished', callEventData: [
|
||||
'scriptPath' => $scriptPath,
|
||||
'tmpPath' => $tmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
}
|
||||
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
|
||||
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting)
|
||||
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
|
||||
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
|
||||
$this->activityId = $activity->id;
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
auditLog('ui.database.import_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
'source' => 'file',
|
||||
'replace_existing' => $this->replaceExisting,
|
||||
]);
|
||||
} catch (DatabaseImportException $e) {
|
||||
$this->importRunning = false;
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
@@ -654,121 +622,23 @@ EOD;
|
||||
|
||||
try {
|
||||
$this->importRunning = true;
|
||||
|
||||
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||
|
||||
$key = $s3Storage->key;
|
||||
$secret = $s3Storage->secret;
|
||||
$bucket = $s3Storage->bucket;
|
||||
$endpoint = $s3Storage->endpoint;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
$cleanPath = ltrim($this->s3Path, '/');
|
||||
|
||||
// Validate the S3 path to prevent command injection
|
||||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
$helperImage = coolifyHelperImage();
|
||||
$latestVersion = getHelperVersion();
|
||||
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||
|
||||
// Get the database destination network
|
||||
if ($this->resource->getMorphClass() === ServiceDatabase::class) {
|
||||
$destinationNetwork = $this->resource->service->destination->network ?? 'coolify';
|
||||
} else {
|
||||
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||
}
|
||||
|
||||
// Generate unique names for this operation
|
||||
$containerName = "s3-restore-{$this->resourceUuid}";
|
||||
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||
$serverTmpPath = "/tmp/s3-restore-{$this->resourceUuid}-".basename($cleanPath);
|
||||
$containerTmpPath = "/tmp/restore_{$this->resourceUuid}-".basename($cleanPath);
|
||||
$scriptPath = "/tmp/restore_{$this->resourceUuid}.sh";
|
||||
|
||||
$escapedServerTmpPath = escapeshellarg($serverTmpPath);
|
||||
$escapedContainerTmpPath = escapeshellarg($containerTmpPath);
|
||||
$escapedScriptPath = escapeshellarg($scriptPath);
|
||||
$escapedHelperContainerPath = escapeshellarg("{$containerName}:{$helperTmpPath}");
|
||||
$escapedDatabaseContainerTmpPath = escapeshellarg("{$this->container}:{$containerTmpPath}");
|
||||
$escapedDatabaseContainerScriptPath = escapeshellarg("{$this->container}:{$scriptPath}");
|
||||
$restoreAndCleanupCommand = escapeshellarg("{$escapedScriptPath} && rm -f {$escapedContainerTmpPath} {$escapedScriptPath}");
|
||||
|
||||
// Prepare all commands in sequence
|
||||
$commands = [];
|
||||
|
||||
// 1. Clean up any existing helper container and temp files from previous runs
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
$commands[] = "docker exec {$this->container} rm -f {$escapedContainerTmpPath} {$escapedScriptPath} 2>/dev/null || true";
|
||||
|
||||
// 2. Start helper container on the database network
|
||||
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||
|
||||
// 3. Configure S3 access in helper container
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
|
||||
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||
|
||||
// 5. Download from S3 to helper container (progress shown by default)
|
||||
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||||
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
$commands[] = "rm -f {$escapedServerTmpPath} 2>/dev/null || true";
|
||||
|
||||
// 8. Build and execute restore command inside database container
|
||||
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$escapedScriptPath}";
|
||||
$commands[] = "chmod +x {$escapedScriptPath}";
|
||||
$commands[] = "docker cp {$escapedScriptPath} {$escapedDatabaseContainerScriptPath}";
|
||||
|
||||
// 9. Execute restore and cleanup temp files immediately after completion
|
||||
$commands[] = "docker exec {$this->container} sh -c {$restoreAndCleanupCommand}";
|
||||
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||
|
||||
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||
'containerName' => $containerName,
|
||||
'serverTmpPath' => $serverTmpPath,
|
||||
'scriptPath' => $scriptPath,
|
||||
'containerTmpPath' => $containerTmpPath,
|
||||
'container' => $this->container,
|
||||
'serverId' => $this->server->id,
|
||||
]);
|
||||
|
||||
// Track the activity ID
|
||||
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting);
|
||||
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
|
||||
$this->activityId = $activity->id;
|
||||
|
||||
// Dispatch activity to the monitor and open slide-over
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
$this->dispatch('databaserestore');
|
||||
auditLog('ui.database.restore_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
'database_name' => $this->resource->name,
|
||||
'source' => 's3',
|
||||
'replace_existing' => $this->replaceExisting,
|
||||
'storage_id' => $this->s3StorageId,
|
||||
]);
|
||||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (DatabaseImportException $e) {
|
||||
$this->importRunning = false;
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
@@ -779,147 +649,8 @@ EOD;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
|
||||
{
|
||||
$script = $this->buildPostgresRestoreScanScript($tmpPath);
|
||||
|
||||
if ($script === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
|
||||
* backup contains directives leading to OS command execution.
|
||||
*
|
||||
* Hardened against bypasses:
|
||||
* - decompresses gzip backups before scanning,
|
||||
* - converts custom-format (PGDMP) archives to SQL with pg_restore
|
||||
* before scanning, and rejects archives that cannot be inspected,
|
||||
* - strips `--` line comments and flattens newlines so multi-line and
|
||||
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
|
||||
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
|
||||
*/
|
||||
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
|
||||
{
|
||||
if (! $this->isPostgresqlRestore()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
|
||||
// Token separator PostgreSQL treats as whitespace: real whitespace or a
|
||||
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
|
||||
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
|
||||
|
||||
$sqlPattern = "(^|;){$sep}*copy{$sep}+[^;]*(from|to){$sep}+program";
|
||||
$psqlPattern = "^{$sep}*\\\\(!|copy{$sep}+[^[:space:]]+.*{$sep}+program|(o|g){$sep}*\\|)";
|
||||
$escapedSqlPattern = escapeshellarg($sqlPattern);
|
||||
$escapedPsqlPattern = escapeshellarg($psqlPattern);
|
||||
$contents = "{ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; }";
|
||||
$scan = static fn (string $source): string => "{$source} | sed 's/--.*//' | grep -Eiq {$escapedPsqlPattern} || {$source} | sed 's/--.*//' | tr '\\n\\r\\t' ' ' | grep -Eiq {$escapedSqlPattern}";
|
||||
$customScan = $scan('pg_restore -f - "$inspect" 2>/dev/null');
|
||||
$sqlScan = $scan($contents);
|
||||
$blockedProgram = 'echo \'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.\'; exit 1';
|
||||
$blockedInspect = 'echo \'Blocked PostgreSQL restore: unable to inspect custom archive.\'; exit 1';
|
||||
|
||||
return <<<SH
|
||||
header=\$({$contents} | head -c 5)
|
||||
if [ "\$header" = 'PGDMP' ]; then
|
||||
inspect=\$(mktemp)
|
||||
trap 'rm -f "\$inspect"' EXIT
|
||||
if ! {$contents} > "\$inspect"; then
|
||||
{$blockedInspect}
|
||||
fi
|
||||
if ! pg_restore -l "\$inspect" >/dev/null 2>&1; then
|
||||
{$blockedInspect}
|
||||
fi
|
||||
if {$customScan}; then
|
||||
{$blockedProgram}
|
||||
fi
|
||||
elif {$sqlScan}; then
|
||||
{$blockedProgram}
|
||||
fi
|
||||
SH;
|
||||
}
|
||||
|
||||
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
|
||||
{
|
||||
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
|
||||
|
||||
if ($command !== null) {
|
||||
$commands[] = $command;
|
||||
}
|
||||
}
|
||||
|
||||
private function isPostgresqlRestore(): bool
|
||||
{
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
return str_contains($this->resource->databaseType(), 'postgres');
|
||||
}
|
||||
|
||||
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
// Handle ServiceDatabase by checking the database type
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
$dbType = $this->resource->databaseType();
|
||||
if (str_contains($dbType, 'mysql')) {
|
||||
$morphClass = 'mysql';
|
||||
} elseif (str_contains($dbType, 'mariadb')) {
|
||||
$morphClass = 'mariadb';
|
||||
} elseif (str_contains($dbType, 'postgres')) {
|
||||
$morphClass = 'postgresql';
|
||||
} elseif (str_contains($dbType, 'mongo')) {
|
||||
$morphClass = 'mongodb';
|
||||
}
|
||||
}
|
||||
|
||||
switch ($morphClass) {
|
||||
case StandaloneMariadb::class:
|
||||
case 'mariadb':
|
||||
$restoreCommand = $this->mariadbRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD \${MARIADB_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
case 'mysql':
|
||||
$restoreCommand = $this->mysqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD \${MYSQL_DATABASE:-default}";
|
||||
} else {
|
||||
$restoreCommand .= " < {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandalonePostgresql::class:
|
||||
case 'postgresql':
|
||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||
if ($this->dumpAll) {
|
||||
$restoreCommand .= " && if [ \"\$({ gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}; } | head -c 5)\" = 'PGDMP' ]; then pg_restore -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}} {$escapedTmpPath}; else (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | psql -U \${POSTGRES_USER} -d \${POSTGRES_DB:-\${POSTGRES_USER:-postgres}}; fi";
|
||||
} else {
|
||||
$restoreCommand .= " {$escapedTmpPath}";
|
||||
}
|
||||
break;
|
||||
case StandaloneMongodb::class:
|
||||
case 'mongodb':
|
||||
$restoreCommand = $this->mongodbRestoreCommand.$escapedTmpPath;
|
||||
break;
|
||||
default:
|
||||
$restoreCommand = '';
|
||||
}
|
||||
|
||||
return $restoreCommand;
|
||||
return app(DatabaseImportCommandBuilder::class)->buildRestoreCommand($this->resource, $tmpPath, $this->dumpAll, $this->replaceExisting);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Project\Service;
|
||||
use App\Actions\Shared\CheckDomainDns;
|
||||
use App\Jobs\CheckDomainDnsJob;
|
||||
use App\Livewire\Concerns\InteractsWithCloudflareDomainConnect;
|
||||
use App\Livewire\Concerns\InteractsWithDnsProviders;
|
||||
use App\Livewire\Project\Shared\ConfigurationChecker;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
@@ -12,6 +13,7 @@ use App\Models\ServiceApplication;
|
||||
use App\Support\DomainPortOverrides;
|
||||
use App\Support\DomainUrlParts;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -21,6 +23,7 @@ class Domains extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use InteractsWithCloudflareDomainConnect;
|
||||
use InteractsWithDnsProviders;
|
||||
|
||||
protected bool $notifyRedirectUpdate = true;
|
||||
|
||||
@@ -118,6 +121,13 @@ class Domains extends Component
|
||||
'confirmDomainUsage',
|
||||
];
|
||||
|
||||
public function getListeners(): array
|
||||
{
|
||||
return array_merge($this->listeners, [
|
||||
'echo-private:team.'.currentTeam()->id.',DnsRecordConfigurationFinished' => 'dnsRecordConfigurationFinished',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
@@ -562,6 +572,11 @@ class Domains extends Component
|
||||
$this->domainRows[$index]['suggestion_role'] = $meta['role'];
|
||||
}
|
||||
|
||||
protected function persistDomainDnsStatuses(): void
|
||||
{
|
||||
$this->persistAllDomainDnsStatuses();
|
||||
}
|
||||
|
||||
protected function persistAllDomainDnsStatuses(): void
|
||||
{
|
||||
$byApp = [];
|
||||
@@ -1065,9 +1080,15 @@ class Domains extends Component
|
||||
$this->pendingAction = null;
|
||||
$this->dispatch('close-modal');
|
||||
$this->refreshDomains();
|
||||
$urlsToCheck = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
$addedUrls = array_values(array_unique(array_merge($newUrls, $pairedUrls)));
|
||||
if ($this->configureDnsAfterDomainAdd($addedUrls)) {
|
||||
$this->dispatch('success', 'Domain added.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$serviceApplicationId = (int) $app->id;
|
||||
$dnsChecks = collect($urlsToCheck)->map(fn (string $url) => [
|
||||
$dnsChecks = collect($addedUrls)->map(fn (string $url) => [
|
||||
'url' => $url,
|
||||
'check_id' => new_public_id(),
|
||||
]);
|
||||
@@ -1313,7 +1334,7 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomain(int $index): void
|
||||
public function removeDomain(int $index, string $password = '', array $selectedActions = []): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->service);
|
||||
@@ -1336,6 +1357,10 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array('deleteManagedDns', $selectedActions, true)) {
|
||||
$this->deleteManagedDnsForUrl($url);
|
||||
}
|
||||
|
||||
$this->forceSaveDomains = false;
|
||||
$this->forceRemovePort = false;
|
||||
$this->dispatch('success', 'Domain removed.');
|
||||
@@ -1346,7 +1371,7 @@ class Domains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function removeDomainByKey(string $domainKey): void
|
||||
public function removeDomainByKey(string $domainKey, string $password = '', array $selectedActions = []): void
|
||||
{
|
||||
$index = collect($this->domainRows)->search(
|
||||
fn (array $row): bool => ! ($row['is_suggested'] ?? false)
|
||||
@@ -1357,7 +1382,7 @@ class Domains extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->removeDomain((int) $index);
|
||||
$this->removeDomain((int) $index, $password, $selectedActions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1368,6 +1393,18 @@ class Domains extends Component
|
||||
return hash('sha256', $row['url'].'|'.$row['service_application_id']);
|
||||
}
|
||||
|
||||
protected function dnsResourceForHostname(string $hostname): ?Model
|
||||
{
|
||||
foreach ($this->domainRows as $row) {
|
||||
$rowHostname = parse_url((string) ($row['url'] ?? ''), PHP_URL_HOST);
|
||||
if (is_string($rowHostname) && strtolower($rowHostname) === strtolower($hostname)) {
|
||||
return $this->findServiceApp((int) $row['service_application_id']);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function addSuggestedDomain(int $index): void
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -116,6 +116,7 @@ class Heading extends Component
|
||||
try {
|
||||
$this->authorizeService('deploy');
|
||||
$activity = StartService::run($this->service, pullLatestImages: true);
|
||||
$this->auditServiceAction('ui.service.started');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -149,6 +150,7 @@ class Heading extends Component
|
||||
try {
|
||||
$this->authorizeService('stop');
|
||||
StopService::dispatch($this->service, false, $this->docker_cleanup);
|
||||
$this->auditServiceAction('ui.service.stopped');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -165,6 +167,7 @@ class Heading extends Component
|
||||
return;
|
||||
}
|
||||
$activity = StartService::run($this->service, stopBeforeStart: true);
|
||||
$this->auditServiceAction('ui.service.restarted');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -206,6 +209,7 @@ class Heading extends Component
|
||||
return;
|
||||
}
|
||||
$activity = StartService::run($this->service, pullLatestImages: true, stopBeforeStart: true);
|
||||
$this->auditServiceAction('ui.service.restarted');
|
||||
$this->js("window.dispatchEvent(new CustomEvent('startservice'))");
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -222,6 +226,15 @@ class Heading extends Component
|
||||
$this->authorize($ability, $this->service);
|
||||
}
|
||||
|
||||
private function auditServiceAction(string $event): void
|
||||
{
|
||||
auditLog($event, [
|
||||
'team_id' => $this->service->team()?->id,
|
||||
'service_uuid' => $this->service->uuid,
|
||||
'service_name' => $this->service->name,
|
||||
]);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.heading', [
|
||||
|
||||
@@ -78,6 +78,7 @@ class Storage extends Component
|
||||
$this->activeTab = $this->resolveDefaultTab();
|
||||
$this->fileStorage = collect();
|
||||
$this->loadFileStorageForActiveTab();
|
||||
$this->name = $this->generateDefaultVolumeName();
|
||||
}
|
||||
|
||||
public function refreshStoragesFromEvent()
|
||||
@@ -208,9 +209,7 @@ class Storage extends Component
|
||||
$this->validate([
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mount_path' => 'required|string',
|
||||
'host_path' => $this->isSwarm
|
||||
? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
|
||||
: ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'host_path' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
], array_merge(ValidationPatterns::volumeNameMessages(), [
|
||||
'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]));
|
||||
@@ -343,7 +342,7 @@ class Storage extends Component
|
||||
|
||||
public function clearForm()
|
||||
{
|
||||
$this->name = '';
|
||||
$this->name = $this->generateDefaultVolumeName();
|
||||
$this->mount_path = '';
|
||||
$this->host_path = null;
|
||||
$this->file_storage_path = '';
|
||||
@@ -376,6 +375,13 @@ class Storage extends Component
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
private function generateDefaultVolumeName(): string
|
||||
{
|
||||
$name = str($this->resource->name)->slug()->value();
|
||||
|
||||
return ($name ?: 'volume').'-data';
|
||||
}
|
||||
|
||||
public function fileStoragePreviewPath(): string
|
||||
{
|
||||
$path = str($this->file_storage_path)->trim();
|
||||
|
||||
@@ -64,6 +64,13 @@ class Destination extends Component
|
||||
$this->authorize('deploy', $this->resource);
|
||||
$server = Server::ownedByCurrentTeam()->findOrFail($serverId);
|
||||
StopApplicationOneServer::run($this->resource, $server);
|
||||
auditLog('ui.application.destination_stopped', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
'application_name' => $this->resource->name,
|
||||
'server_uuid' => $server->uuid,
|
||||
'server_name' => $server->name,
|
||||
]);
|
||||
$this->refreshServers();
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -9,14 +9,27 @@ use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\HasSecretManagerAutocomplete;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
if ($this->shared || ! $this->resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public $resource;
|
||||
|
||||
public $parameters;
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ use App\Models\SharedEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use App\Traits\HasSecretManagerAutocomplete;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -22,7 +24,12 @@ class Show extends Component
|
||||
{
|
||||
public bool $showEnvironmentType = true;
|
||||
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
return $this->isSharedVariable ? null : $this->env->resourceable;
|
||||
}
|
||||
|
||||
public $parameters;
|
||||
|
||||
@@ -164,7 +171,24 @@ class Show extends Component
|
||||
$this->valuesLoaded = true;
|
||||
}
|
||||
|
||||
public function copyValue(): ?string
|
||||
{
|
||||
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $this->env instanceof ModelsEnvironmentVariable) {
|
||||
return $this->env->value;
|
||||
}
|
||||
|
||||
return $this->env->get_real_environment_variables_with_server(
|
||||
$this->env->resolveReferencedValue(),
|
||||
$this->env->resourceable,
|
||||
);
|
||||
}
|
||||
|
||||
private function syncData(bool $toModel = false): void
|
||||
|
||||
{
|
||||
if ($toModel) {
|
||||
$this->key = ValidationPatterns::normalizeEnvironmentVariableKey($this->key);
|
||||
@@ -207,7 +231,7 @@ class Show extends Component
|
||||
$this->is_required = (bool) ($this->env->is_required ?? false);
|
||||
// Use the stored column, not the value-based accessor (that decrypts).
|
||||
$this->is_shared = (bool) ($this->env->getAttributes()['is_shared'] ?? false);
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? false;
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
|
||||
if ($this->valuesLoaded) {
|
||||
$this->hydrateValueFields();
|
||||
@@ -234,12 +258,12 @@ class Show extends Component
|
||||
$this->is_really_required = $this->is_required && blank($this->value);
|
||||
}
|
||||
|
||||
if ($this->env->is_shown_once || auth()->user()?->isMember()) {
|
||||
if ($this->env->is_shown_once || (auth()->user()?->isMember() ?? true)) {
|
||||
$this->value = null;
|
||||
$this->real_value = null;
|
||||
}
|
||||
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? false;
|
||||
$this->isValueHidden = auth()->user()?->isMember() ?? true;
|
||||
}
|
||||
|
||||
public function checkEnvs()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use Livewire\Component;
|
||||
|
||||
class ShowHardcoded extends Component
|
||||
@@ -20,6 +21,10 @@ class ShowHardcoded extends Component
|
||||
|
||||
public bool $isPreview = false;
|
||||
|
||||
public ?string $resourceableType = null;
|
||||
|
||||
public ?int $resourceableId = null;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->key = $this->env['key'];
|
||||
@@ -28,6 +33,20 @@ class ShowHardcoded extends Component
|
||||
$this->serviceName = $this->env['service_name'] ?? null;
|
||||
}
|
||||
|
||||
public function copyValue(): ?string
|
||||
{
|
||||
if (auth()->user()?->isMember() ?? true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return EnvironmentVariable::make([
|
||||
'value' => $this->value,
|
||||
'is_preview' => $this->isPreview,
|
||||
'resourceable_type' => $this->resourceableType,
|
||||
'resourceable_id' => $this->resourceableId,
|
||||
])->resolveReferencedValue();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.environment-variable.show-hardcoded');
|
||||
|
||||
@@ -86,6 +86,14 @@ class ResourceOperations extends Component
|
||||
if (! $server->canHostResources()) {
|
||||
return $this->addError('destination_id', 'The selected server cannot host resources.');
|
||||
}
|
||||
auditLog('ui.resource.clone_started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'resource_uuid' => $this->resource->uuid,
|
||||
'resource_name' => $this->resource->name,
|
||||
'resource_type' => class_basename($this->resource),
|
||||
'destination_uuid' => $new_destination->uuid,
|
||||
'environment_id' => $new_environment->id,
|
||||
]);
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$new_resource = clone_application($this->resource, $new_destination, [
|
||||
|
||||
@@ -184,6 +184,13 @@ class Show extends Component
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->authorize('update', $this->task);
|
||||
ScheduledTaskJob::dispatch($this->task);
|
||||
auditLog('ui.scheduled_task.executed', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'resource_uuid' => $this->resource->uuid,
|
||||
'resource_name' => $this->resource->name,
|
||||
'scheduled_task_uuid' => $this->task->uuid,
|
||||
'scheduled_task_name' => $this->task->name,
|
||||
]);
|
||||
$this->dispatch('success', 'Scheduled task executed.');
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Manages a resource's single secret manager source and lets the user
|
||||
* browse remote key names, add {{vault.KEY}} reference variables, and import
|
||||
* all missing keys. Secret values never enter the component state or the DB.
|
||||
*/
|
||||
class SecretManagerLinks extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public $link;
|
||||
|
||||
public $availableTokens;
|
||||
|
||||
public string $integration_token_uuid = '';
|
||||
|
||||
public array $settings = [];
|
||||
|
||||
/** @var list<string> Remote key names only — values are never stored. */
|
||||
public array $keys = [];
|
||||
|
||||
public bool $keysLoaded = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
private function loadData(): void
|
||||
{
|
||||
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
|
||||
$this->availableTokens = IntegrationToken::ownedByCurrentTeam()
|
||||
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
|
||||
->get()
|
||||
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
|
||||
->values();
|
||||
|
||||
if ($this->link) {
|
||||
$this->integration_token_uuid = $this->link->integrationToken->uuid;
|
||||
$this->settings = $this->link->settings ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelectedTokenProperty(): ?IntegrationToken
|
||||
{
|
||||
if (blank($this->integration_token_uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'integration_token_uuid' => ['required', 'string'],
|
||||
];
|
||||
|
||||
$rules += match ($this->selectedToken?->provider) {
|
||||
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
|
||||
? [
|
||||
'settings.project' => ['required', 'string'],
|
||||
'settings.config' => ['required', 'string'],
|
||||
]
|
||||
: [],
|
||||
'infisical' => [
|
||||
'settings.project_id' => ['required', 'string'],
|
||||
'settings.environment' => ['required', 'string'],
|
||||
'settings.secret_path' => ['nullable', 'string'],
|
||||
],
|
||||
'vault' => [
|
||||
'settings.mount' => ['required', 'string'],
|
||||
'settings.path' => ['required', 'string'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
|
||||
* references are intentionally NOT re-checked — missing keys surface at
|
||||
* the next deployment.
|
||||
*/
|
||||
public function updatedIntegrationTokenUuid(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$token = $this->selectedToken;
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->link?->integrationToken?->provider !== $token->provider
|
||||
|| $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
|
||||
$this->settings = [];
|
||||
}
|
||||
|
||||
$settings = array_filter($this->settings, fn ($value) => filled($value));
|
||||
|
||||
$this->resource->secretManagerLink()->updateOrCreate([], [
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
$this->auditSecretManagerAction('source_updated', [
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
'provider' => $token->provider,
|
||||
]);
|
||||
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save of the provider-specific settings fields (called on blur).
|
||||
*/
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $this->validate();
|
||||
|
||||
try {
|
||||
|
||||
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
|
||||
|
||||
$this->link->update(['settings' => $settings ?: null]);
|
||||
$this->auditSecretManagerAction('settings_updated');
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager settings saved.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeSource(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$token = $this->link?->integrationToken;
|
||||
$this->resource->secretManagerLink()->delete();
|
||||
$this->auditSecretManagerAction('source_removed', [
|
||||
'integration_token_uuid' => $token?->uuid,
|
||||
'provider' => $token?->provider,
|
||||
]);
|
||||
$this->link = null;
|
||||
$this->integration_token_uuid = '';
|
||||
$this->settings = [];
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function loadKeys(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Values are fetched into memory, reduced to key names, and discarded.
|
||||
$keys = array_keys($this->link->fetchSecrets());
|
||||
sort($keys);
|
||||
$this->keys = $keys;
|
||||
$this->keysLoaded = true;
|
||||
$this->auditSecretManagerAction('keys_viewed', ['key_count' => count($keys)]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function addReference(string $key): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! in_array($key, $this->keys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
|
||||
$this->dispatch('error', "A variable with the key {$key} already exists.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resource->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => '{{vault.'.$key.'}}',
|
||||
]);
|
||||
$this->auditSecretManagerAction('reference_created', ['secret_key' => $key]);
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function importAll(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
$imported = $this->link->importMissingReferences();
|
||||
$this->auditSecretManagerAction('references_imported', [
|
||||
'key_count' => count($imported),
|
||||
'secret_keys' => $imported,
|
||||
]);
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('success', $imported === []
|
||||
? 'All remote keys already exist as variables.'
|
||||
: 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function resetKeys(): void
|
||||
{
|
||||
$this->keys = [];
|
||||
$this->keysLoaded = false;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
private function auditSecretManagerAction(string $action, array $context = []): void
|
||||
{
|
||||
$resourceType = str(class_basename($this->resource))->snake()->value();
|
||||
|
||||
auditLog("ui.{$resourceType}.secret_manager.{$action}", array_merge([
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
"{$resourceType}_uuid" => $this->resource->uuid,
|
||||
"{$resourceType}_name" => $this->resource->name,
|
||||
], $context));
|
||||
}
|
||||
|
||||
public function getFilteredKeysProperty(): array
|
||||
{
|
||||
if (blank($this->search)) {
|
||||
return $this->keys;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->keys,
|
||||
fn (string $key) => stripos($key, $this->search) !== false,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.secret-manager-links', [
|
||||
'selectedToken' => $this->selectedToken,
|
||||
'filteredKeys' => $this->filteredKeys,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,25 @@ class All extends Component
|
||||
$this->submit($storageId);
|
||||
}
|
||||
|
||||
public function clearHostPath(int $storageId): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$storage = $this->findStorageOrFail($storageId);
|
||||
if ($storage->shouldBeReadOnlyInUI()) {
|
||||
$this->dispatch('error', 'This volume is read-only.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$storage->host_path = null;
|
||||
$storage->save();
|
||||
$this->forms[$storageId]['hostPath'] = null;
|
||||
|
||||
$this->dispatch('configurationChanged');
|
||||
$this->dispatch('success', 'Source path removed. Use a directory mount for host directory bindings.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire listbox onChange cannot pass args; PR suffix fields call this via updatedForms.
|
||||
*/
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared\Storages;
|
||||
|
||||
use App\Livewire\Project\Service\Storage as StorageComponent;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\ScheduledVolumeBackup;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public LocalPersistentVolume $storage;
|
||||
|
||||
public $resource;
|
||||
|
||||
public bool $isReadOnly = false;
|
||||
|
||||
public bool $isFirst = true;
|
||||
|
||||
public bool $isService = false;
|
||||
|
||||
public ?string $startedAt = null;
|
||||
|
||||
public bool $supportsPreviewSuffix = false;
|
||||
|
||||
// Explicit properties
|
||||
public string $name;
|
||||
|
||||
public string $mountPath;
|
||||
|
||||
public ?string $hostPath = null;
|
||||
|
||||
public bool $isPreviewSuffixEnabled = true;
|
||||
|
||||
public bool $hasEnabledBackup = false;
|
||||
|
||||
public ?string $backupUrl = null;
|
||||
|
||||
/**
|
||||
* When true, parent already batched badge/url data — skip per-row queries on mount.
|
||||
*/
|
||||
public bool $backupMetaHydrated = false;
|
||||
|
||||
/** When true, the Backup Configure Livewire modal is mounted (lazy). */
|
||||
public bool $showBackupModal = false;
|
||||
|
||||
protected $validationAttributes = [
|
||||
'name' => 'name',
|
||||
'mountPath' => 'mount',
|
||||
'hostPath' => 'host',
|
||||
];
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ValidationPatterns::volumeNameRules(),
|
||||
'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'isPreviewSuffixEnabled' => 'required|boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return array_merge(
|
||||
ValidationPatterns::volumeNameMessages(),
|
||||
[
|
||||
'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
|
||||
'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync data between component properties and model
|
||||
*
|
||||
* @param bool $toModel If true, sync FROM properties TO model. If false, sync FROM model TO properties.
|
||||
*/
|
||||
private function syncData(bool $toModel = false): void
|
||||
{
|
||||
if ($toModel) {
|
||||
// Sync TO model (before save)
|
||||
$this->storage->name = $this->name;
|
||||
$this->storage->mount_path = $this->mountPath;
|
||||
$this->storage->host_path = $this->hostPath;
|
||||
$this->storage->is_preview_suffix_enabled = $this->isPreviewSuffixEnabled;
|
||||
} else {
|
||||
// Sync FROM model (on load/refresh)
|
||||
$this->name = $this->storage->name;
|
||||
$this->mountPath = $this->storage->mount_path;
|
||||
$this->hostPath = $this->storage->host_path;
|
||||
$this->isPreviewSuffixEnabled = $this->storage->is_preview_suffix_enabled ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->syncData(false);
|
||||
$this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
|
||||
// PR deployment volume suffixes only apply to git-based applications.
|
||||
$this->supportsPreviewSuffix = $this->resource instanceof Application
|
||||
&& $this->resource->git_based()
|
||||
&& filled($this->resource->git_repository)
|
||||
&& ! $this->isService;
|
||||
// Parent All batches badge/url; isolated embeds still hydrate themselves.
|
||||
if (! $this->backupMetaHydrated) {
|
||||
$this->refreshBackupStatus();
|
||||
}
|
||||
}
|
||||
|
||||
#[On('refreshVolumeBackups')]
|
||||
public function refreshBackupStatus(): void
|
||||
{
|
||||
$backup = $this->storage->scheduledBackups()->first();
|
||||
|
||||
$this->hasEnabledBackup = $backup?->enabled ?? false;
|
||||
$this->backupUrl = null;
|
||||
|
||||
if (! $this->hasEnabledBackup || ! $this->resource instanceof Application) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resource->loadMissing('environment.project');
|
||||
|
||||
$parameters = [
|
||||
'project_uuid' => $this->resource->project()->uuid,
|
||||
'environment_uuid' => $this->resource->environment->uuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
];
|
||||
$hasOtherBackups = ScheduledVolumeBackup::query()
|
||||
->forApplication($this->resource)
|
||||
->where('id', '!=', $backup->id)
|
||||
->exists();
|
||||
|
||||
$this->backupUrl = $hasOtherBackups
|
||||
? route('project.application.backup.index', [...$parameters, 'search' => $this->storage->name])
|
||||
: route('project.application.backup.show', [...$parameters, 'backup_uuid' => $backup->uuid]);
|
||||
}
|
||||
|
||||
public function openBackupModal(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->showBackupModal = true;
|
||||
}
|
||||
|
||||
#[On('modalClosed')]
|
||||
public function onModalClosed(): void
|
||||
{
|
||||
// Drop the nested Create component from the DOM after close to free snapshot weight.
|
||||
if ($this->showBackupModal) {
|
||||
$this->showBackupModal = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->validate();
|
||||
|
||||
$this->syncData(true);
|
||||
$this->storage->save();
|
||||
$this->dispatch('success', 'Storage updated successfully');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate();
|
||||
$this->syncData(true);
|
||||
$this->storage->save();
|
||||
$this->dispatch('success', 'Storage updated successfully');
|
||||
}
|
||||
|
||||
public function delete($password, $selectedActions = [])
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
if ($this->storage->scheduledBackups()->exists()) {
|
||||
$this->dispatch('error', 'Delete this volume backup schedule and its archives before deleting the volume.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->storage->delete();
|
||||
$this->dispatch('storageCountsChanged')->to(StorageComponent::class);
|
||||
$this->dispatch('configurationChanged');
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,12 @@ class VolumeBackups extends Component
|
||||
}
|
||||
|
||||
VolumeBackupJob::dispatch($this->backup);
|
||||
auditLog('ui.volume_backup.started', [
|
||||
'team_id' => $this->resource->team()?->id,
|
||||
'resource_uuid' => $this->resource->uuid,
|
||||
'resource_name' => $this->resource->name,
|
||||
'backup_uuid' => $this->backup->uuid,
|
||||
]);
|
||||
$this->dispatch('success', 'Storage backup queued.');
|
||||
|
||||
return redirect()->route($this->routeName('executions'), $this->routeParameters());
|
||||
|
||||
@@ -140,6 +140,12 @@ class ApiTokens extends Component
|
||||
]);
|
||||
$expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
|
||||
$token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
|
||||
auditLog('ui.api_token.created', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'api_token_name' => $this->description,
|
||||
'abilities' => array_values($this->permissions),
|
||||
'expires_at' => $expiresAt?->toIso8601String(),
|
||||
]);
|
||||
$this->getTokens();
|
||||
// Do NOT strip the numeric prefix (e.g. "69|...") — Sanctum uses it to index and look up tokens.
|
||||
session()->flash('token', $token->plainTextToken);
|
||||
@@ -156,7 +162,12 @@ class ApiTokens extends Component
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
$this->authorize('delete', $token);
|
||||
$tokenName = $token->name;
|
||||
$token->delete();
|
||||
auditLog('ui.api_token.revoked', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'api_token_name' => $tokenName,
|
||||
]);
|
||||
$this->getTokens();
|
||||
} catch (\Exception $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokenEditor extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public IntegrationToken $integrationToken;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $newToken = '';
|
||||
|
||||
public array $capabilities = [];
|
||||
|
||||
public array $metadata = [];
|
||||
|
||||
public int $zoneCount = 0;
|
||||
|
||||
/** @var array<int, array{id: int, name: string, account_name: ?string, managed_records_count: int}> */
|
||||
public array $zones = [];
|
||||
|
||||
public bool $automaticDns = true;
|
||||
|
||||
public function mount(string $integration_token_uuid): void
|
||||
{
|
||||
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
|
||||
->whereUuid($integration_token_uuid)
|
||||
->firstOrFail();
|
||||
|
||||
$this->authorize('view', $this->integrationToken);
|
||||
|
||||
$this->name = $this->integrationToken->name;
|
||||
$this->capabilities = $this->integrationToken->capabilities;
|
||||
$this->metadata = $this->integrationToken->metadata ?? [];
|
||||
$this->loadZones();
|
||||
$this->automaticDns = $this->integrationToken->automaticDnsEnabled();
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
|
||||
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'newToken' => ['nullable', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
'automaticDns' => ['boolean'],
|
||||
];
|
||||
|
||||
if ($this->integrationToken->provider === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
if ($this->integrationToken->provider === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'capabilities.required' => 'Select at least one capability.',
|
||||
'capabilities.min' => 'Select at least one capability.',
|
||||
];
|
||||
}
|
||||
|
||||
public function save(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
|
||||
{
|
||||
$this->authorize('update', $this->integrationToken);
|
||||
$validated = $this->validate();
|
||||
$provider = $this->integrationToken->provider;
|
||||
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
|
||||
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
|
||||
if ($provider === 'cloudflare') {
|
||||
if ($validated['automaticDns']) {
|
||||
unset($metadata['automatic_dns']);
|
||||
} else {
|
||||
$metadata['automatic_dns'] = false;
|
||||
}
|
||||
}
|
||||
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
|
||||
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
|
||||
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
|
||||
|
||||
try {
|
||||
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
|
||||
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
|
||||
$this->dispatch('error', $validator->errorMessage($provider));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [
|
||||
'name' => $validated['name'],
|
||||
'capabilities' => $validated['capabilities'],
|
||||
'metadata' => $metadata ?: null,
|
||||
];
|
||||
|
||||
if (filled($validated['newToken'])) {
|
||||
$updates['token'] = $validated['newToken'];
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($updates, $provider, $validated, $capabilitiesChanged, $cloudflare): void {
|
||||
$this->integrationToken->update($updates);
|
||||
if ($provider === 'cloudflare' && (filled($validated['newToken']) || $capabilitiesChanged)) {
|
||||
$cloudflare->syncZones($this->integrationToken);
|
||||
}
|
||||
});
|
||||
$this->newToken = '';
|
||||
$this->loadZones();
|
||||
|
||||
auditLog('ui.integration_token.updated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'integration_token_uuid' => $this->integrationToken->uuid,
|
||||
'integration_token_name' => $this->integrationToken->name,
|
||||
'provider' => $this->integrationToken->provider,
|
||||
'rotated' => array_key_exists('token', $updates),
|
||||
]);
|
||||
|
||||
$this->dispatch(
|
||||
'integration-token-updated',
|
||||
uuid: $this->integrationToken->uuid,
|
||||
name: $this->integrationToken->name,
|
||||
capabilities: $this->integrationToken->capabilities,
|
||||
);
|
||||
$this->dispatch('success', 'Integration token updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(string $password = ''): void
|
||||
{
|
||||
$this->authorize('delete', $this->integrationToken);
|
||||
|
||||
if ($this->integrationToken->secretManagerLinks()->exists()) {
|
||||
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->integrationToken->managedDnsRecords()->exists()) {
|
||||
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$uuid = $this->integrationToken->uuid;
|
||||
$name = $this->integrationToken->name;
|
||||
$provider = $this->integrationToken->provider;
|
||||
$this->integrationToken->delete();
|
||||
|
||||
auditLog('ui.integration_token.deleted', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'integration_token_uuid' => $uuid,
|
||||
'integration_token_name' => $name,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
|
||||
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
|
||||
$this->dispatch('close-modal');
|
||||
$this->dispatch('success', 'Integration token deleted successfully.');
|
||||
}
|
||||
|
||||
public function refreshZones(CloudflareDnsProvider $cloudflare): void
|
||||
{
|
||||
$this->authorize('update', $this->integrationToken);
|
||||
try {
|
||||
$cloudflare->syncZones($this->integrationToken);
|
||||
$this->integrationToken->refresh();
|
||||
$this->loadZones();
|
||||
$this->dispatch('success', "Cloudflare zones refreshed. {$this->zoneCount} accessible zones found.");
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-token-editor');
|
||||
}
|
||||
|
||||
private function loadZones(): void
|
||||
{
|
||||
$this->zones = $this->integrationToken->dnsZones()
|
||||
->select(['id', 'integration_token_id', 'name', 'account_name'])
|
||||
->withCount('managedRecords')
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->map(fn ($zone) => [
|
||||
'id' => $zone->id,
|
||||
'name' => $zone->name,
|
||||
'account_name' => $zone->account_name,
|
||||
'managed_records_count' => $zone->managed_records_count,
|
||||
])
|
||||
->all();
|
||||
$this->zoneCount = count($this->zones);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\Dns\CloudflareDnsProvider;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokenForm extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public bool $modal_mode = false;
|
||||
|
||||
public string $provider = 'cloudflare';
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $token = '';
|
||||
|
||||
public array $capabilities = ['dns'];
|
||||
|
||||
public array $metadata = [];
|
||||
|
||||
public bool $automaticDns = true;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
}
|
||||
|
||||
public function updatedProvider(): void
|
||||
{
|
||||
if ($this->provider === 'cloudflare') {
|
||||
$this->capabilities = ['dns'];
|
||||
$this->metadata = [];
|
||||
$this->automaticDns = true;
|
||||
} else {
|
||||
$this->capabilities = ['secrets'];
|
||||
$this->metadata = $this->provider === 'infisical'
|
||||
? ['base_url' => 'https://app.infisical.com']
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
|
||||
|
||||
$rules = [
|
||||
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
'automaticDns' => ['boolean'],
|
||||
];
|
||||
|
||||
if ($this->provider === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
if ($this->provider === 'doppler') {
|
||||
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
|
||||
}
|
||||
|
||||
if ($this->provider === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
{
|
||||
return [
|
||||
'capabilities.required' => 'Select at least one capability.',
|
||||
'capabilities.min' => 'Select at least one capability.',
|
||||
'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
|
||||
];
|
||||
}
|
||||
|
||||
public function addToken(IntegrationTokenValidator $validator, CloudflareDnsProvider $cloudflare): void
|
||||
{
|
||||
$validated = $this->validate();
|
||||
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
|
||||
if ($validated['provider'] === 'cloudflare' && ! $validated['automaticDns']) {
|
||||
$metadata['automatic_dns'] = false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
|
||||
$this->dispatch('error', $validator->errorMessage($validated['provider']));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$integrationToken = DB::transaction(function () use ($validated, $metadata, $cloudflare): IntegrationToken {
|
||||
$token = IntegrationToken::query()->create([
|
||||
'provider' => $validated['provider'], 'name' => $validated['name'], 'token' => $validated['token'],
|
||||
'capabilities' => $validated['capabilities'], 'metadata' => $metadata ?: null, 'team_id' => currentTeam()->id,
|
||||
]);
|
||||
if ($token->provider === 'cloudflare') {
|
||||
$cloudflare->syncZones($token);
|
||||
}
|
||||
|
||||
return $token;
|
||||
});
|
||||
|
||||
auditLog('ui.integration_token.created', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'integration_token_uuid' => $integrationToken->uuid,
|
||||
'integration_token_name' => $integrationToken->name,
|
||||
'provider' => $integrationToken->provider,
|
||||
]);
|
||||
|
||||
$this->reset(['name', 'token']);
|
||||
$this->dispatch('integrationTokenAdded')->to(IntegrationTokens::class);
|
||||
|
||||
if ($this->modal_mode) {
|
||||
$this->dispatch('close-modal');
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Integration token added successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-token-form');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
||||
class IntegrationTokens extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $tokens;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('viewAny', IntegrationToken::class);
|
||||
$this->loadTokens();
|
||||
}
|
||||
|
||||
#[On('integrationTokenAdded')]
|
||||
public function loadTokens(): void
|
||||
{
|
||||
$this->tokens = IntegrationToken::ownedByCurrentTeam()->withCount('dnsZones')->latest()->get();
|
||||
}
|
||||
|
||||
public function deleteToken(int $tokenId, string $password = ''): void
|
||||
{
|
||||
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->secretManagerLinks()->exists()) {
|
||||
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($token->managedDnsRecords()->exists()) {
|
||||
$this->dispatch('error', 'This token manages DNS records. Remove those domains or records first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenUuid = $token->uuid;
|
||||
$tokenName = $token->name;
|
||||
$provider = $token->provider;
|
||||
$token->delete();
|
||||
auditLog('ui.integration_token.deleted', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'integration_token_uuid' => $tokenUuid,
|
||||
'integration_token_name' => $tokenName,
|
||||
'provider' => $provider,
|
||||
]);
|
||||
$this->loadTokens();
|
||||
$this->dispatch('success', 'Integration token deleted successfully.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.security.integration-tokens');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server\Analytics;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\View\View;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('view', $this->server);
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.server.analytics.show');
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Livewire\Server;
|
||||
use App\Actions\Server\StartSentinel;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
class Charts extends Component
|
||||
@@ -23,15 +24,44 @@ class Charts extends Component
|
||||
|
||||
public bool $poll = true;
|
||||
|
||||
#[Validate(['required', 'integer', 'min:1'])]
|
||||
public int|string $sentinelMetricsRefreshRateSeconds;
|
||||
|
||||
#[Validate(['required', 'integer', 'min:1'])]
|
||||
public int|string $sentinelMetricsHistoryDays;
|
||||
|
||||
#[Validate(['required', 'integer', 'min:10'])]
|
||||
public int|string $sentinelPushIntervalSeconds;
|
||||
|
||||
public function mount(string $server_uuid)
|
||||
{
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds;
|
||||
$this->sentinelMetricsHistoryDays = $this->server->settings->sentinel_metrics_history_days;
|
||||
$this->sentinelPushIntervalSeconds = $this->server->settings->sentinel_push_interval_seconds;
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function saveMetricsSettings(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->validate();
|
||||
|
||||
$this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds;
|
||||
$this->server->settings->sentinel_metrics_history_days = $this->sentinelMetricsHistoryDays;
|
||||
$this->server->settings->sentinel_push_interval_seconds = $this->sentinelPushIntervalSeconds;
|
||||
$this->server->settings->save();
|
||||
|
||||
$this->dispatch('success', 'Metrics settings updated. Restarting Sentinel.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleMetrics(): void
|
||||
{
|
||||
try {
|
||||
@@ -70,11 +100,9 @@ class Charts extends Component
|
||||
try {
|
||||
$cpuMetrics = $this->server->getCpuMetrics($this->interval);
|
||||
$memoryMetrics = $this->server->getMemoryMetrics($this->interval);
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-cpu", [
|
||||
'seriesData' => $cpuMetrics,
|
||||
]);
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-memory", [
|
||||
'seriesData' => $memoryMetrics,
|
||||
$this->dispatch("refreshChartData-{$this->chartId}-metrics", [
|
||||
'cpuSeries' => $cpuMetrics,
|
||||
'memorySeries' => $memoryMetrics,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -134,6 +134,13 @@ class DockerCleanup extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
DockerCleanupJob::dispatch($this->server, true, $this->deleteUnusedVolumes, $this->deleteUnusedNetworks);
|
||||
auditLog('ui.server.docker_cleanup_started', [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
'delete_unused_volumes' => $this->deleteUnusedVolumes,
|
||||
'delete_unused_networks' => $this->deleteUnusedNetworks,
|
||||
]);
|
||||
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -177,6 +177,49 @@ class LogDrains extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function toggleLogDrain(string $type): void
|
||||
{
|
||||
$previousNewRelicEnabled = $this->server->settings->is_logdrain_newrelic_enabled;
|
||||
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
|
||||
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
$this->resetErrorBag();
|
||||
|
||||
$enabledProperty = $this->enabledProperty($type);
|
||||
|
||||
if ($this->{$enabledProperty}) {
|
||||
$this->{$enabledProperty} = false;
|
||||
} else {
|
||||
$this->validateLogDrainSettings($type);
|
||||
$this->isLogDrainNewRelicEnabled = $type === 'newrelic';
|
||||
$this->isLogDrainAxiomEnabled = $type === 'axiom';
|
||||
$this->isLogDrainCustomEnabled = $type === 'custom';
|
||||
}
|
||||
|
||||
$this->syncData(true);
|
||||
|
||||
if ($this->server->isLogDrainEnabled()) {
|
||||
StartLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service started.');
|
||||
} else {
|
||||
StopLogDrain::run($this->server);
|
||||
$this->dispatch('success', 'Log drain service stopped.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Restore the previously persisted enabled flags so the UI/DB never
|
||||
// claim a runtime state that the Start/StopLogDrain action failed to apply.
|
||||
$this->server->settings->is_logdrain_newrelic_enabled = $previousNewRelicEnabled;
|
||||
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
|
||||
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
|
||||
$this->server->settings->save();
|
||||
$this->syncData();
|
||||
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -192,4 +235,33 @@ class LogDrains extends Component
|
||||
{
|
||||
return view('livewire.server.log-drains');
|
||||
}
|
||||
|
||||
private function enabledProperty(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'newrelic' => 'isLogDrainNewRelicEnabled',
|
||||
'axiom' => 'isLogDrainAxiomEnabled',
|
||||
'custom' => 'isLogDrainCustomEnabled',
|
||||
default => throw new \InvalidArgumentException('Unknown log drain type.'),
|
||||
};
|
||||
}
|
||||
|
||||
private function validateLogDrainSettings(string $type): void
|
||||
{
|
||||
match ($type) {
|
||||
'newrelic' => $this->validate([
|
||||
'logDrainNewRelicLicenseKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainNewRelicBaseUri' => ['required', 'url'],
|
||||
]),
|
||||
'axiom' => $this->validate([
|
||||
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
|
||||
]),
|
||||
'custom' => $this->validate([
|
||||
'logDrainCustomConfig' => ['required'],
|
||||
'logDrainCustomConfigParser' => ['string', 'nullable'],
|
||||
]),
|
||||
default => throw new \InvalidArgumentException('Unknown log drain type.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ class Navbar extends Component
|
||||
|
||||
// Always use background job for all servers
|
||||
RestartProxyJob::dispatch($this->server);
|
||||
auditLog('ui.proxy.restarted', [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
$this->restartInitiated = false;
|
||||
@@ -125,6 +130,11 @@ class Navbar extends Component
|
||||
try {
|
||||
$this->authorize('manageProxy', $this->server);
|
||||
$activity = StartProxy::run($this->server, force: true);
|
||||
auditLog('ui.proxy.started', [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
]);
|
||||
$this->dispatch('activityMonitor', $activity->id);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -136,6 +146,12 @@ class Navbar extends Component
|
||||
try {
|
||||
$this->authorize('manageProxy', $this->server);
|
||||
StopProxy::dispatch($this->server, $forceStop);
|
||||
auditLog('ui.proxy.stopped', [
|
||||
'team_id' => $this->server->team_id,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'server_name' => $this->server->name,
|
||||
'force' => $forceStop,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ class Proxy extends Component
|
||||
$this->redirectEnabled = data_get($this->server, 'proxy.redirect_enabled', true);
|
||||
$this->redirectUrl = data_get($this->server, 'proxy.redirect_url');
|
||||
$this->syncData(false);
|
||||
$this->loadProxyConfiguration();
|
||||
$this->clearAppliedTraefikBranchWarning();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user