mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Compare commits
22 commits
822c479dab
...
e76e18cf71
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e76e18cf71 | ||
|
|
6bcae50e49 | ||
|
|
db55c8160a | ||
|
|
60dfadf036 | ||
|
|
27e2680d70 | ||
|
|
65d61a4af3 | ||
|
|
b5151815c1 | ||
|
|
184fbb98f3 | ||
|
|
a5367408d0 | ||
|
|
574f849778 | ||
|
|
19d1662fac | ||
|
|
e3daba0b1d | ||
|
|
7bee8a5668 | ||
|
|
4615cfd007 | ||
|
|
31caef990d | ||
|
|
380a34c7d6 | ||
|
|
ffacd18100 | ||
|
|
cc96403cbe | ||
|
|
040658c142 | ||
|
|
34c5eb9e10 | ||
|
|
30c1d9bbd0 | ||
|
|
8cc10ab10a |
42 changed files with 1257 additions and 22 deletions
|
|
@ -51,9 +51,11 @@ class StartDatabaseProxy
|
|||
}
|
||||
|
||||
$configuration_dir = database_proxy_dir($database->uuid);
|
||||
$host_configuration_dir = $configuration_dir;
|
||||
if (isDev()) {
|
||||
$configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
|
||||
$host_configuration_dir = '/var/lib/docker/volumes/coolify_dev_coolify_data/_data/databases/'.$database->uuid.'/proxy';
|
||||
}
|
||||
$timeoutConfig = $this->buildProxyTimeoutConfig($database->public_port_timeout);
|
||||
$nginxconf = <<<EOF
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
|
|
@ -67,6 +69,7 @@ class StartDatabaseProxy
|
|||
server {
|
||||
listen $database->public_port;
|
||||
proxy_pass $containerName:$internalPort;
|
||||
$timeoutConfig
|
||||
}
|
||||
}
|
||||
EOF;
|
||||
|
|
@ -85,7 +88,7 @@ class StartDatabaseProxy
|
|||
'volumes' => [
|
||||
[
|
||||
'type' => 'bind',
|
||||
'source' => "$configuration_dir/nginx.conf",
|
||||
'source' => "$host_configuration_dir/nginx.conf",
|
||||
'target' => '/etc/nginx/nginx.conf',
|
||||
],
|
||||
],
|
||||
|
|
@ -160,4 +163,13 @@ class StartDatabaseProxy
|
|||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function buildProxyTimeoutConfig(?int $timeout): string
|
||||
{
|
||||
if ($timeout === null || $timeout < 1) {
|
||||
$timeout = 3600;
|
||||
}
|
||||
|
||||
return "proxy_timeout {$timeout}s;";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -805,9 +805,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||
);
|
||||
|
||||
$this->write_deployment_configurations();
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
if ($this->preserveRepository) {
|
||||
$this->execute_remote_command(
|
||||
['command' => "cd {$server_workdir} && {$start_command}", 'hidden' => true],
|
||||
);
|
||||
} else {
|
||||
$this->execute_remote_command(
|
||||
[executeInDocker($this->deployment_uuid, "cd {$this->basedir} && {$start_command}"), 'hidden' => true],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$command = "{$this->coolify_variables} docker compose";
|
||||
if ($this->preserveRepository) {
|
||||
|
|
|
|||
|
|
@ -51,9 +51,7 @@ class Configuration extends Component
|
|||
$this->environment = $environment;
|
||||
$this->application = $application;
|
||||
|
||||
if ($this->application->deploymentType() === 'deploy_key' && $this->currentRoute === 'project.application.preview-deployments') {
|
||||
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
|
||||
}
|
||||
|
||||
|
||||
if ($this->application->build_pack === 'dockercompose' && $this->currentRoute === 'project.application.healthcheck') {
|
||||
return redirect()->route('project.application.configuration', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]);
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
|
@ -80,6 +82,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable|string',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
|
|
@ -99,6 +102,8 @@ class General extends Component
|
|||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -115,6 +120,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->save();
|
||||
|
|
@ -130,6 +136,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->dbUrl = $this->database->internal_db_url;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
|
@ -91,6 +93,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable|string',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
|
|
@ -109,6 +112,8 @@ class General extends Component
|
|||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -124,6 +129,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
|
|
@ -139,6 +145,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->enable_ssl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -401,20 +401,24 @@ EOD;
|
|||
}
|
||||
}
|
||||
|
||||
public function runImport()
|
||||
public function runImport(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->filename === '') {
|
||||
$this->dispatch('error', 'Please select a file to import.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -434,7 +438,7 @@ EOD;
|
|||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
|
|
@ -442,7 +446,7 @@ EOD;
|
|||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Copy the restore command to a script file
|
||||
|
|
@ -474,11 +478,15 @@ EOD;
|
|||
$this->dispatch('databaserestore');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
handleError($e, $this);
|
||||
|
||||
return true;
|
||||
} finally {
|
||||
$this->filename = null;
|
||||
$this->importCommands = [];
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function loadAvailableS3Storages()
|
||||
|
|
@ -577,26 +585,30 @@ EOD;
|
|||
}
|
||||
}
|
||||
|
||||
public function restoreFromS3()
|
||||
public function restoreFromS3(string $password = ''): bool|string
|
||||
{
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return 'The provided password is incorrect.';
|
||||
}
|
||||
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_null($this->s3FileSize)) {
|
||||
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->server) {
|
||||
$this->dispatch('error', 'Server not found. Please refresh the page.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -613,7 +625,7 @@ EOD;
|
|||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Clean the S3 path
|
||||
|
|
@ -623,7 +635,7 @@ EOD;
|
|||
if (! $this->validateS3Path($cleanPath)) {
|
||||
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get helper image
|
||||
|
|
@ -711,9 +723,12 @@ EOD;
|
|||
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||
} catch (\Throwable $e) {
|
||||
$this->importRunning = false;
|
||||
handleError($e, $this);
|
||||
|
||||
return handleError($e, $this);
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
||||
public ?string $dbUrl = null;
|
||||
|
|
@ -94,6 +96,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable|string',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'customDockerRunOptions' => 'nullable|string',
|
||||
'dbUrl' => 'nullable|string',
|
||||
'dbUrlPublic' => 'nullable|string',
|
||||
|
|
@ -114,6 +117,8 @@ class General extends Component
|
|||
'image.required' => 'The Docker Image field is required.',
|
||||
'image.string' => 'The Docker Image must be a string.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -130,6 +135,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->enable_ssl = $this->enable_ssl;
|
||||
|
|
@ -146,6 +152,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->enable_ssl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
|
@ -79,6 +81,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
|
|
@ -97,6 +100,8 @@ class General extends Component
|
|||
'mariadbDatabase.required' => 'The MariaDB Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -113,6 +118,7 @@ class General extends Component
|
|||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
];
|
||||
|
|
@ -154,6 +160,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
|
|
@ -173,6 +180,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
|
@ -78,6 +80,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
|
|
@ -96,6 +99,8 @@ class General extends Component
|
|||
'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-full.',
|
||||
]
|
||||
);
|
||||
|
|
@ -112,6 +117,7 @@ class General extends Component
|
|||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
|
|
@ -153,6 +159,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
|
|
@ -172,6 +179,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
|
@ -81,6 +83,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
|
|
@ -100,6 +103,8 @@ class General extends Component
|
|||
'mysqlDatabase.required' => 'The MySQL Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: PREFERRED, REQUIRED, VERIFY_CA, VERIFY_IDENTITY.',
|
||||
]
|
||||
);
|
||||
|
|
@ -117,6 +122,7 @@ class General extends Component
|
|||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
|
|
@ -159,6 +165,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
|
|
@ -179,6 +186,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
|
@ -93,6 +95,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'enableSsl' => 'boolean',
|
||||
|
|
@ -111,6 +114,8 @@ class General extends Component
|
|||
'postgresDb.required' => 'The Postgres Database field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
'sslMode.in' => 'The SSL Mode must be one of: allow, prefer, require, verify-ca, verify-full.',
|
||||
]
|
||||
);
|
||||
|
|
@ -130,6 +135,7 @@ class General extends Component
|
|||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Run Options',
|
||||
'enableSsl' => 'Enable SSL',
|
||||
'sslMode' => 'SSL Mode',
|
||||
|
|
@ -174,6 +180,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
|
|
@ -196,6 +203,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ class General extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
public ?string $customDockerRunOptions = null;
|
||||
|
|
@ -74,6 +76,7 @@ class General extends Component
|
|||
'portsMappings' => 'nullable',
|
||||
'isPublic' => 'nullable|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isLogDrainEnabled' => 'nullable|boolean',
|
||||
'customDockerRunOptions' => 'nullable',
|
||||
'redisUsername' => 'required',
|
||||
|
|
@ -90,6 +93,8 @@ class General extends Component
|
|||
'name.required' => 'The Name field is required.',
|
||||
'image.required' => 'The Docker Image field is required.',
|
||||
'publicPort.integer' => 'The Public Port must be an integer.',
|
||||
'publicPortTimeout.integer' => 'The Public Port Timeout must be an integer.',
|
||||
'publicPortTimeout.min' => 'The Public Port Timeout must be at least 1.',
|
||||
'redisUsername.required' => 'The Redis Username field is required.',
|
||||
'redisPassword.required' => 'The Redis Password field is required.',
|
||||
]
|
||||
|
|
@ -104,6 +109,7 @@ class General extends Component
|
|||
'portsMappings' => 'Port Mapping',
|
||||
'isPublic' => 'Is Public',
|
||||
'publicPort' => 'Public Port',
|
||||
'publicPortTimeout' => 'Public Port Timeout',
|
||||
'customDockerRunOptions' => 'Custom Docker Options',
|
||||
'redisUsername' => 'Redis Username',
|
||||
'redisPassword' => 'Redis Password',
|
||||
|
|
@ -143,6 +149,7 @@ class General extends Component
|
|||
$this->database->ports_mappings = $this->portsMappings;
|
||||
$this->database->is_public = $this->isPublic;
|
||||
$this->database->public_port = $this->publicPort;
|
||||
$this->database->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
|
||||
$this->database->enable_ssl = $this->enableSsl;
|
||||
|
|
@ -158,6 +165,7 @@ class General extends Component
|
|||
$this->portsMappings = $this->database->ports_mappings;
|
||||
$this->isPublic = $this->database->is_public;
|
||||
$this->publicPort = $this->database->public_port;
|
||||
$this->publicPortTimeout = $this->database->public_port_timeout;
|
||||
$this->isLogDrainEnabled = $this->database->is_log_drain_enabled;
|
||||
$this->customDockerRunOptions = $this->database->custom_docker_run_options;
|
||||
$this->enableSsl = $this->database->enable_ssl;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ class Index extends Component
|
|||
|
||||
public ?int $publicPort = null;
|
||||
|
||||
public ?int $publicPortTimeout = 3600;
|
||||
|
||||
public bool $isPublic = false;
|
||||
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
|
@ -90,6 +92,7 @@ class Index extends Component
|
|||
'image' => 'required',
|
||||
'excludeFromStatus' => 'required|boolean',
|
||||
'publicPort' => 'nullable|integer',
|
||||
'publicPortTimeout' => 'nullable|integer|min:1',
|
||||
'isPublic' => 'required|boolean',
|
||||
'isLogDrainEnabled' => 'required|boolean',
|
||||
// Application-specific rules
|
||||
|
|
@ -158,6 +161,7 @@ class Index extends Component
|
|||
$this->serviceDatabase->image = $this->image;
|
||||
$this->serviceDatabase->exclude_from_status = $this->excludeFromStatus;
|
||||
$this->serviceDatabase->public_port = $this->publicPort;
|
||||
$this->serviceDatabase->public_port_timeout = $this->publicPortTimeout;
|
||||
$this->serviceDatabase->is_public = $this->isPublic;
|
||||
$this->serviceDatabase->is_log_drain_enabled = $this->isLogDrainEnabled;
|
||||
} else {
|
||||
|
|
@ -166,6 +170,7 @@ class Index extends Component
|
|||
$this->image = $this->serviceDatabase->image;
|
||||
$this->excludeFromStatus = $this->serviceDatabase->exclude_from_status ?? false;
|
||||
$this->publicPort = $this->serviceDatabase->public_port;
|
||||
$this->publicPortTimeout = $this->serviceDatabase->public_port_timeout;
|
||||
$this->isPublic = $this->serviceDatabase->is_public ?? false;
|
||||
$this->isLogDrainEnabled = $this->serviceDatabase->is_log_drain_enabled ?? false;
|
||||
}
|
||||
|
|
|
|||
550
app/Livewire/Project/Shared/FileBrowser.php
Normal file
550
app/Livewire/Project/Shared/FileBrowser.php
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithFileUploads;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
class FileBrowser extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public string $selected_container = 'default';
|
||||
|
||||
public Collection $containers;
|
||||
|
||||
public array $parameters;
|
||||
|
||||
public $resource;
|
||||
|
||||
public string $type;
|
||||
|
||||
public Collection $servers;
|
||||
|
||||
public string $currentPath = '/';
|
||||
|
||||
public array $entries = [];
|
||||
|
||||
public bool $isLoading = false;
|
||||
|
||||
public bool $showCreateFolder = false;
|
||||
|
||||
public string $newFolderName = '';
|
||||
|
||||
public $uploadFile;
|
||||
|
||||
public bool $isUploading = false;
|
||||
|
||||
private const MAX_DOWNLOAD_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->containers = collect();
|
||||
$this->servers = collect();
|
||||
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
$this->type = 'application';
|
||||
$this->resource = Application::where('uuid', $this->parameters['application_uuid'])->firstOrFail();
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
foreach ($this->resource->additional_servers as $server) {
|
||||
if ($server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($server);
|
||||
}
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'database_uuid')) {
|
||||
$this->type = 'database';
|
||||
$resource = getResourceByUuid($this->parameters['database_uuid'], data_get(auth()->user()->currentTeam(), 'id'));
|
||||
if (is_null($resource)) {
|
||||
abort(404);
|
||||
}
|
||||
$this->resource = $resource;
|
||||
if ($this->resource->destination->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->destination->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->type = 'service';
|
||||
$this->resource = Service::where('uuid', $this->parameters['service_uuid'])->firstOrFail();
|
||||
if ($this->resource->server->isFunctional()) {
|
||||
$this->servers = $this->servers->push($this->resource->server);
|
||||
}
|
||||
$this->loadContainers();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadContainers(): void
|
||||
{
|
||||
foreach ($this->servers as $server) {
|
||||
if (data_get($this->parameters, 'application_uuid')) {
|
||||
if ($server->isSwarm()) {
|
||||
$containers = collect([
|
||||
[
|
||||
'Names' => $this->resource->uuid.'_'.$this->resource->uuid,
|
||||
],
|
||||
]);
|
||||
} else {
|
||||
$containers = getCurrentApplicationContainerStatus($server, $this->resource->id, includePullrequests: true);
|
||||
}
|
||||
foreach ($containers as $container) {
|
||||
if (data_get($container, 'State') === 'running') {
|
||||
$this->containers = $this->containers->push([
|
||||
'server' => $server,
|
||||
'container' => $container,
|
||||
]);
|
||||
}
|
||||
}
|
||||
} elseif (data_get($this->parameters, 'database_uuid')) {
|
||||
if ($this->resource->isRunning()) {
|
||||
$this->containers = $this->containers->push([
|
||||
'server' => $server,
|
||||
'container' => [
|
||||
'Names' => $this->resource->uuid,
|
||||
],
|
||||
]);
|
||||
}
|
||||
} elseif (data_get($this->parameters, 'service_uuid')) {
|
||||
$this->resource->applications()->get()->each(function ($application) {
|
||||
if ($application->isRunning()) {
|
||||
$this->containers->push([
|
||||
'server' => $this->resource->server,
|
||||
'container' => [
|
||||
'Names' => data_get($application, 'name').'-'.data_get($this->resource, 'uuid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
$this->resource->databases()->get()->each(function ($database) {
|
||||
if ($database->isRunning()) {
|
||||
$this->containers->push([
|
||||
'server' => $this->resource->server,
|
||||
'container' => [
|
||||
'Names' => data_get($database, 'name').'-'.data_get($this->resource, 'uuid'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$this->containers = $this->containers->sortBy(fn ($container) => data_get($container, 'container.Names'));
|
||||
|
||||
if ($this->containers->count() === 1) {
|
||||
$this->selected_container = data_get($this->containers->first(), 'container.Names');
|
||||
$this->browse('/');
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedContainer(): void
|
||||
{
|
||||
if ($this->selected_container !== 'default') {
|
||||
$this->browse('/');
|
||||
}
|
||||
}
|
||||
|
||||
public function browse(string $path): void
|
||||
{
|
||||
if (! $this->validatePath($path)) {
|
||||
$this->dispatch('error', 'Invalid path.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isLoading = true;
|
||||
|
||||
try {
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($path);
|
||||
|
||||
$output = instant_remote_process([
|
||||
"docker exec {$escapedContainer} ls -la {$escapedPath} 2>&1",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->entries = $this->parseLsOutput($output);
|
||||
$this->currentPath = $path;
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to browse: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function navigateTo(int $index): void
|
||||
{
|
||||
if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid entry.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
$newPath = rtrim($this->currentPath, '/').'/'.ltrim($name, '/');
|
||||
$this->browse($newPath);
|
||||
}
|
||||
|
||||
public function navigateUp(): void
|
||||
{
|
||||
if ($this->currentPath === '/') {
|
||||
return;
|
||||
}
|
||||
$parent = dirname($this->currentPath);
|
||||
$this->browse($parent);
|
||||
}
|
||||
|
||||
public function createFolder(): void
|
||||
{
|
||||
if (empty(trim($this->newFolderName))) {
|
||||
$this->dispatch('error', 'Folder name cannot be empty.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-zA-Z0-9._\-]+$/', $this->newFolderName)) {
|
||||
$this->dispatch('error', 'Folder name contains invalid characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$folderPath = rtrim($this->currentPath, '/').'/'.$this->newFolderName;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($folderPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$escapedContainer} mkdir -p {$escapedPath}",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->newFolderName = '';
|
||||
$this->showCreateFolder = false;
|
||||
$this->dispatch('success', 'Folder created.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to create folder: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteEntry(int $index): void
|
||||
{
|
||||
if (! isset($this->entries[$index])) {
|
||||
$this->dispatch('error', 'Invalid entry.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$entryPath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($entryPath);
|
||||
|
||||
instant_remote_process([
|
||||
"docker exec {$escapedContainer} rm -rf {$escapedPath}",
|
||||
], $resolved['server']);
|
||||
|
||||
$this->dispatch('success', 'Deleted successfully.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to delete: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadFile(int $index): mixed
|
||||
{
|
||||
if (! isset($this->entries[$index]) || $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid file.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$filePath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($filePath);
|
||||
|
||||
$sizeOutput = instant_remote_process([
|
||||
"docker exec {$escapedContainer} stat -c %s {$escapedPath} 2>/dev/null || echo 0",
|
||||
], $resolved['server'], throwError: false);
|
||||
|
||||
$fileSize = (int) trim($sizeOutput);
|
||||
|
||||
if ($fileSize > self::MAX_DOWNLOAD_SIZE) {
|
||||
$this->dispatch('error', 'File is too large to download via browser (max 100MB). Use the terminal instead.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'base64 {$escapedPath}'",
|
||||
], $resolved['server']);
|
||||
|
||||
$decoded = base64_decode(str_replace("\n", '', $content));
|
||||
|
||||
return response()->streamDownload(function () use ($decoded) {
|
||||
echo $decoded;
|
||||
}, $name);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to download: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public function uploadToContainer(): void
|
||||
{
|
||||
if (is_null($this->uploadFile)) {
|
||||
$this->dispatch('error', 'No file selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isUploading = true;
|
||||
|
||||
try {
|
||||
$originalName = $this->uploadFile->getClientOriginalName();
|
||||
if (! preg_match('/^[a-zA-Z0-9._\- ]+$/', $originalName)) {
|
||||
$this->dispatch('error', 'File name contains invalid characters.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$uuid = (string) new Cuid2;
|
||||
$localPath = $this->uploadFile->store("tmp/filebrowser-{$uuid}");
|
||||
$fullLocalPath = storage_path('app/'.$localPath);
|
||||
|
||||
$remoteTmpPath = "/tmp/coolify-upload-{$uuid}";
|
||||
$containerDest = rtrim($this->currentPath, '/').'/'.$originalName;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedRemoteTmp = escapeshellarg($remoteTmpPath);
|
||||
$escapedContainerDest = escapeshellarg($containerDest);
|
||||
|
||||
instant_scp($fullLocalPath, $remoteTmpPath, $resolved['server']);
|
||||
|
||||
instant_remote_process([
|
||||
"docker cp {$escapedRemoteTmp} {$escapedContainer}:{$escapedContainerDest}",
|
||||
], $resolved['server']);
|
||||
|
||||
instant_remote_process([
|
||||
"rm -f {$escapedRemoteTmp}",
|
||||
], $resolved['server']);
|
||||
|
||||
@unlink($fullLocalPath);
|
||||
@rmdir(dirname($fullLocalPath));
|
||||
|
||||
$this->uploadFile = null;
|
||||
$this->dispatch('success', 'File uploaded.');
|
||||
$this->browse($this->currentPath);
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to upload: '.$e->getMessage());
|
||||
} finally {
|
||||
$this->isUploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function downloadFolder(int $index): mixed
|
||||
{
|
||||
if (! isset($this->entries[$index]) || ! $this->entries[$index]['isDirectory']) {
|
||||
$this->dispatch('error', 'Invalid folder.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = $this->entries[$index]['name'];
|
||||
|
||||
$resolved = $this->resolveContainerAndServer();
|
||||
if (is_null($resolved)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$folderPath = rtrim($this->currentPath, '/').'/'.$name;
|
||||
$escapedContainer = escapeshellarg($resolved['containerName']);
|
||||
$escapedPath = escapeshellarg($folderPath);
|
||||
|
||||
$sizeOutput = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'du -sb {$escapedPath} 2>/dev/null | cut -f1 || echo 0'",
|
||||
], $resolved['server'], throwError: false);
|
||||
|
||||
$folderSize = (int) trim($sizeOutput);
|
||||
|
||||
if ($folderSize > self::MAX_DOWNLOAD_SIZE) {
|
||||
$this->dispatch('error', 'Folder is too large to download via browser (max 100MB). Use the terminal instead.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = instant_remote_process([
|
||||
"docker exec {$escapedContainer} sh -c 'tar czf - -C ".escapeshellarg(dirname($folderPath)).' '.escapeshellarg($name)." | base64'",
|
||||
], $resolved['server']);
|
||||
|
||||
$decoded = base64_decode(str_replace("\n", '', $content));
|
||||
|
||||
return response()->streamDownload(function () use ($decoded) {
|
||||
echo $decoded;
|
||||
}, $name.'.tar.gz');
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Failed to download folder: '.$e->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveContainerAndServer(): ?array
|
||||
{
|
||||
if ($this->selected_container === 'default') {
|
||||
$this->dispatch('error', 'Please select a container.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $this->selected_container)) {
|
||||
$this->dispatch('error', 'Invalid container name.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$container = collect($this->containers)->firstWhere('container.Names', $this->selected_container);
|
||||
if (is_null($container)) {
|
||||
$this->dispatch('error', 'Container not found.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$server = data_get($container, 'server');
|
||||
if (! $server || ! $server instanceof Server) {
|
||||
$this->dispatch('error', 'Invalid server configuration.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($server->isForceDisabled()) {
|
||||
$this->dispatch('error', 'Server is disabled.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'containerName' => data_get($container, 'container.Names'),
|
||||
'server' => $server,
|
||||
];
|
||||
}
|
||||
|
||||
private function validatePath(string $path): bool
|
||||
{
|
||||
if (! str_starts_with($path, '/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($path, '..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (preg_match('/[`$|;&<>!\\\]/', $path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($path, "\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array{permissions: string, links: int, owner: string, group: string, size: int, modified: string, name: string, isDirectory: bool, isSymlink: bool, linkTarget: ?string}>
|
||||
*/
|
||||
private function parseLsOutput(?string $output): array
|
||||
{
|
||||
if (empty($output)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = explode("\n", trim($output));
|
||||
$entries = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line) || str_starts_with($line, 'total ')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('/^([d\-lbcps][rwxsStT\-]{9})\s+(\d+)\s+(\S+)\s+(\S+)\s+([\d,]+)\s+(.{12,18})\s+(.+)$/', $line, $matches)) {
|
||||
$name = $matches[7];
|
||||
if ($name === '.' || $name === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$isSymlink = str_starts_with($matches[1], 'l');
|
||||
$linkTarget = null;
|
||||
if ($isSymlink && str_contains($name, ' -> ')) {
|
||||
[$name, $linkTarget] = explode(' -> ', $name, 2);
|
||||
}
|
||||
|
||||
$sizeStr = str_replace(',', '', $matches[5]);
|
||||
|
||||
$entries[] = [
|
||||
'permissions' => $matches[1],
|
||||
'links' => (int) $matches[2],
|
||||
'owner' => $matches[3],
|
||||
'group' => $matches[4],
|
||||
'size' => (int) $sizeStr,
|
||||
'modified' => trim($matches[6]),
|
||||
'name' => $name,
|
||||
'isDirectory' => str_starts_with($matches[1], 'd'),
|
||||
'isSymlink' => $isSymlink,
|
||||
'linkTarget' => $linkTarget,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
usort($entries, function ($a, $b) {
|
||||
if ($a['isDirectory'] !== $b['isDirectory']) {
|
||||
return $b['isDirectory'] <=> $a['isDirectory'];
|
||||
}
|
||||
|
||||
return strcasecmp($a['name'], $b['name']);
|
||||
});
|
||||
|
||||
return $entries;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.file-browser');
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel
|
|||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'public_port_timeout' => 'integer',
|
||||
];
|
||||
|
||||
protected static function booted()
|
||||
{
|
||||
static::deleting(function ($service) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class StandaloneClickhouse extends BaseModel
|
|||
|
||||
protected $casts = [
|
||||
'clickhouse_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class StandaloneDragonfly extends BaseModel
|
|||
|
||||
protected $casts = [
|
||||
'dragonfly_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class StandaloneKeydb extends BaseModel
|
|||
|
||||
protected $casts = [
|
||||
'keydb_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class StandaloneMariadb extends BaseModel
|
|||
|
||||
protected $casts = [
|
||||
'mariadb_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class StandaloneMongodb extends BaseModel
|
|||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class StandaloneMysql extends BaseModel
|
|||
protected $casts = [
|
||||
'mysql_password' => 'encrypted',
|
||||
'mysql_root_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ class StandalonePostgresql extends BaseModel
|
|||
protected $casts = [
|
||||
'init_scripts' => 'array',
|
||||
'postgres_password' => 'encrypted',
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class StandaloneRedis extends BaseModel
|
|||
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
|
||||
|
||||
protected $casts = [
|
||||
'public_port_timeout' => 'integer',
|
||||
'restart_count' => 'integer',
|
||||
'last_restart_at' => 'datetime',
|
||||
'last_restart_type' => 'string',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$tables = [
|
||||
'standalone_postgresqls',
|
||||
'standalone_mysqls',
|
||||
'standalone_mariadbs',
|
||||
'standalone_redis',
|
||||
'standalone_mongodbs',
|
||||
'standalone_clickhouses',
|
||||
'standalone_keydbs',
|
||||
'standalone_dragonflies',
|
||||
'service_databases',
|
||||
];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (Schema::hasTable($table) && !Schema::hasColumn($table, 'public_port_timeout')) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->integer('public_port_timeout')->nullable()->default(3600)->after('public_port');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$tables = [
|
||||
'standalone_postgresqls',
|
||||
'standalone_mysqls',
|
||||
'standalone_mariadbs',
|
||||
'standalone_redis',
|
||||
'standalone_mongodbs',
|
||||
'standalone_clickhouses',
|
||||
'standalone_keydbs',
|
||||
'standalone_dragonflies',
|
||||
'service_databases',
|
||||
];
|
||||
|
||||
foreach ($tables as $table) {
|
||||
if (Schema::hasTable($table) && Schema::hasColumn($table, 'public_port_timeout')) {
|
||||
Schema::table($table, function (Blueprint $table) {
|
||||
$table->dropColumn('public_port_timeout');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
href="{{ route('project.application.scheduled-tasks.show', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Scheduled Tasks</span></a>
|
||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.webhooks', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Webhooks</span></a>
|
||||
@if ($application->deploymentType() !== 'deploy_key')
|
||||
@if ($application->git_based())
|
||||
<a class="sub-menu-item" {{ wireNavigate() }} wire:current.exact="menu-item-active"
|
||||
href="{{ route('project.application.preview-deployments', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid, 'application_uuid' => $application->uuid]) }}"><span class="menu-item-label">Preview Deployments</span></a>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@
|
|||
href="{{ route('project.application.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.application.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.application.file-browser', $parameters) }}">
|
||||
Files
|
||||
</a>
|
||||
@endcan
|
||||
@endif
|
||||
<x-applications.links :application="$application" />
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@
|
|||
href="{{ route('project.database.command', $parameters) }}">
|
||||
Terminal
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.database.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.database.file-browser', $parameters) }}">
|
||||
Files
|
||||
</a>
|
||||
@endcan
|
||||
@if (
|
||||
$database->getMorphClass() === 'App\Models\StandalonePostgresql' ||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.textarea
|
||||
helper="<a target='_blank' class='underline dark:text-white' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>KeyDB Default Configuration</a>"
|
||||
|
|
|
|||
|
|
@ -139,6 +139,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MariaDB Configuration" rows="10" id="mariadbConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MongoDB Configuration" rows="10" id="mongoConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
|
|
|||
|
|
@ -155,6 +155,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.textarea label="Custom Mysql Configuration" rows="10" id="mysqlConf" canGate="update" :canResource="$database" />
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
|
|
|
|||
|
|
@ -165,6 +165,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -134,6 +134,8 @@
|
|||
</div>
|
||||
<x-forms.input placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.textarea placeholder="# maxmemory 256mb
|
||||
# maxmemory-policy allkeys-lru
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@
|
|||
href="{{ route('project.service.command', $parameters) }}">
|
||||
<button>Terminal</button>
|
||||
</a>
|
||||
<a class="{{ request()->routeIs('project.service.file-browser') ? 'dark:text-white' : '' }}"
|
||||
href="{{ route('project.service.file-browser', $parameters) }}">
|
||||
<button>Files</button>
|
||||
</a>
|
||||
@endcan
|
||||
<x-services.links :service="$service" />
|
||||
</nav>
|
||||
|
|
|
|||
194
resources/views/livewire/project/shared/file-browser.blade.php
Normal file
194
resources/views/livewire/project/shared/file-browser.blade.php
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
<div>
|
||||
<x-slot:title>
|
||||
{{ data_get_str($resource, 'name')->limit(10) }} > File Browser | Coolify
|
||||
</x-slot>
|
||||
@if ($type === 'application')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<h1>File Browser</h1>
|
||||
<livewire:project.application.heading :application="$resource" />
|
||||
@elseif ($type === 'database')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<h1>File Browser</h1>
|
||||
<livewire:project.database.heading :database="$resource" />
|
||||
@elseif ($type === 'service')
|
||||
<livewire:project.shared.configuration-checker :resource="$resource" />
|
||||
<livewire:project.service.heading :service="$resource" :parameters="$parameters" title="File Browser" />
|
||||
@endif
|
||||
|
||||
<h2 class="pb-4">File Browser</h2>
|
||||
|
||||
@if (count($containers) === 0)
|
||||
<div>No running containers found or terminal access is disabled on this server.</div>
|
||||
@else
|
||||
{{-- Container selector --}}
|
||||
<div class="flex gap-2 items-end pb-4">
|
||||
<x-forms.select label="Container" wire:model.live="selected_container" class="w-96">
|
||||
@foreach ($containers as $container)
|
||||
@if ($loop->first)
|
||||
<option disabled value="default">Select a container</option>
|
||||
@endif
|
||||
<option value="{{ data_get($container, 'container.Names') }}">
|
||||
{{ data_get($container, 'container.Names') }}
|
||||
({{ data_get($container, 'server.name') }})
|
||||
</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
|
||||
@if ($selected_container !== 'default')
|
||||
{{-- Breadcrumb navigation --}}
|
||||
<div class="flex items-center gap-1 pb-4 text-sm" x-data>
|
||||
<button wire:click="browse('/')" class="dark:text-white hover:underline font-bold">/</button>
|
||||
@php
|
||||
$pathParts = array_filter(explode('/', $currentPath));
|
||||
$accumulated = '';
|
||||
@endphp
|
||||
@foreach ($pathParts as $part)
|
||||
@php $accumulated .= '/' . $part; @endphp
|
||||
<span class="dark:text-neutral-400">/</span>
|
||||
<button wire:click="browse('{{ $accumulated }}')" class="hover:underline dark:text-white">{{ $part }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
<div class="flex flex-wrap gap-2 items-center pb-4">
|
||||
<x-forms.button wire:click="navigateUp" title="Go up one directory">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 11l-5-5-5 5"/>
|
||||
<path d="M17 18l-5-5-5 5"/>
|
||||
</svg>
|
||||
Up
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="browse('{{ $currentPath }}')" title="Refresh">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2"/>
|
||||
</svg>
|
||||
Refresh
|
||||
</x-forms.button>
|
||||
<x-forms.button wire:click="$set('showCreateFolder', true)" title="Create folder">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
New Folder
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
{{-- Create folder form --}}
|
||||
@if ($showCreateFolder)
|
||||
<div class="flex gap-2 items-end pb-4">
|
||||
<x-forms.input wire:model="newFolderName" label="Folder Name" placeholder="new-folder" required />
|
||||
<x-forms.button wire:click="createFolder">Create</x-forms.button>
|
||||
<x-forms.button wire:click="$set('showCreateFolder', false)">Cancel</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Upload --}}
|
||||
<div class="flex gap-2 items-end pb-4" x-data="{ uploading: false }" x-on:livewire-upload-start="uploading = true" x-on:livewire-upload-finish="uploading = false" x-on:livewire-upload-error="uploading = false">
|
||||
<div class="flex gap-2 items-end">
|
||||
<div>
|
||||
<label class="block text-sm font-medium pb-1">Upload File</label>
|
||||
<input type="file" wire:model="uploadFile" class="block text-sm file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:bg-coollabs file:text-white hover:file:bg-coollabs-100 dark:text-neutral-300" />
|
||||
</div>
|
||||
<x-forms.button wire:click="uploadToContainer" :disabled="$isUploading">
|
||||
<span x-show="!uploading && !@js($isUploading)">Upload</span>
|
||||
<span x-show="uploading || @js($isUploading)">Uploading...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Loading indicator --}}
|
||||
<div wire:loading wire:target="browse,navigateTo,navigateUp,createFolder,deleteEntry,uploadToContainer" class="pb-2">
|
||||
<x-loading />
|
||||
</div>
|
||||
|
||||
{{-- File listing --}}
|
||||
<div wire:loading.remove wire:target="browse,navigateTo,navigateUp">
|
||||
@if (count($entries) === 0 && $selected_container !== 'default')
|
||||
<div class="dark:text-neutral-400">This directory is empty.</div>
|
||||
@else
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr class="dark:text-neutral-400 border-b dark:border-neutral-700">
|
||||
<th class="text-left py-2 px-2">Permissions</th>
|
||||
<th class="text-left py-2 px-2">Owner</th>
|
||||
<th class="text-left py-2 px-2">Group</th>
|
||||
<th class="text-right py-2 px-2">Size</th>
|
||||
<th class="text-left py-2 px-2">Modified</th>
|
||||
<th class="text-left py-2 px-2">Name</th>
|
||||
<th class="text-right py-2 px-2">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($entries as $entry)
|
||||
<tr class="border-b dark:border-neutral-800 hover:dark:bg-neutral-800/50">
|
||||
<td class="py-1.5 px-2 font-mono text-xs">{{ $entry['permissions'] }}</td>
|
||||
<td class="py-1.5 px-2">{{ $entry['owner'] }}</td>
|
||||
<td class="py-1.5 px-2">{{ $entry['group'] }}</td>
|
||||
<td class="py-1.5 px-2 text-right font-mono">
|
||||
@if ($entry['isDirectory'])
|
||||
—
|
||||
@else
|
||||
{{ formatBytes($entry['size']) }}
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-xs whitespace-nowrap">{{ $entry['modified'] }}</td>
|
||||
<td class="py-1.5 px-2">
|
||||
@if ($entry['isDirectory'])
|
||||
<button wire:click="navigateTo({{ $loop->index }})" class="flex items-center gap-1 hover:underline dark:text-white font-medium">
|
||||
<svg class="w-4 h-4 text-yellow-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 4a2 2 0 0 1 2-2h4.586a2 2 0 0 1 1.414.586l1.414 1.414H20a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4z"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
</button>
|
||||
@elseif ($entry['isSymlink'])
|
||||
<span class="flex items-center gap-1 dark:text-blue-400">
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
@if ($entry['linkTarget'])
|
||||
<span class="dark:text-neutral-500">-> {{ $entry['linkTarget'] }}</span>
|
||||
@endif
|
||||
</span>
|
||||
@else
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-4 h-4 dark:text-neutral-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
</svg>
|
||||
{{ $entry['name'] }}
|
||||
</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="py-1.5 px-2 text-right whitespace-nowrap">
|
||||
@if ($entry['isDirectory'])
|
||||
<button wire:click="downloadFolder({{ $loop->index }})" class="text-xs hover:underline dark:text-neutral-400 hover:dark:text-white" title="Download as .tar.gz">
|
||||
Download
|
||||
</button>
|
||||
<span class="dark:text-neutral-600 px-1">|</span>
|
||||
@else
|
||||
<button wire:click="downloadFile({{ $loop->index }})" class="text-xs hover:underline dark:text-neutral-400 hover:dark:text-white" title="Download file">
|
||||
Download
|
||||
</button>
|
||||
<span class="dark:text-neutral-600 px-1">|</span>
|
||||
@endif
|
||||
<button
|
||||
x-data="{ entryName: @js($entry['name']) }"
|
||||
x-on:click="if (confirm('Delete ' + entryName + '? This cannot be undone.')) { $wire.deleteEntry({{ $loop->index }}) }"
|
||||
class="text-xs hover:underline text-error"
|
||||
title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -32,6 +32,7 @@ use App\Livewire\Project\Service\Configuration as ServiceConfiguration;
|
|||
use App\Livewire\Project\Service\DatabaseBackups as ServiceDatabaseBackups;
|
||||
use App\Livewire\Project\Service\Index as ServiceIndex;
|
||||
use App\Livewire\Project\Shared\ExecuteContainerCommand;
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
use App\Livewire\Project\Shared\Logs;
|
||||
use App\Livewire\Project\Shared\ScheduledTask\Show as ScheduledTaskShow;
|
||||
use App\Livewire\Project\Show as ProjectShow;
|
||||
|
|
@ -215,6 +216,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/deployment/{deployment_uuid}', DeploymentShow::class)->name('project.application.deployment.show');
|
||||
Route::get('/logs', Logs::class)->name('project.application.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.application.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.application.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/tasks/{task_uuid}', ScheduledTaskShow::class)->name('project.application.scheduled-tasks');
|
||||
});
|
||||
Route::prefix('project/{project_uuid}/environment/{environment_uuid}/database/{database_uuid}')->group(function () {
|
||||
|
|
@ -232,6 +234,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
|
||||
Route::get('/logs', Logs::class)->name('project.database.logs');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.database.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.database.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/backups', DatabaseBackupIndex::class)->name('project.database.backup.index');
|
||||
Route::get('/backups/{backup_uuid}', DatabaseBackupExecution::class)->name('project.database.backup.execution');
|
||||
});
|
||||
|
|
@ -246,6 +249,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::get('/tags', ServiceConfiguration::class)->name('project.service.tags');
|
||||
Route::get('/danger', ServiceConfiguration::class)->name('project.service.danger');
|
||||
Route::get('/terminal', ExecuteContainerCommand::class)->name('project.service.command')->middleware('can.access.terminal');
|
||||
Route::get('/file-browser', FileBrowser::class)->name('project.service.file-browser')->middleware('can.access.terminal');
|
||||
Route::get('/{stack_service_uuid}/backups', ServiceDatabaseBackups::class)->name('project.service.database.backups');
|
||||
Route::get('/{stack_service_uuid}/import', ServiceIndex::class)->name('project.service.database.import')->middleware('can.update.resource');
|
||||
Route::get('/{stack_service_uuid}', ServiceIndex::class)->name('project.service.index');
|
||||
|
|
|
|||
|
|
@ -43,3 +43,15 @@ test('isNonTransientError detects port conflict patterns', function () {
|
|||
->and($method->invoke($action, 'network timeout'))->toBeFalse()
|
||||
->and($method->invoke($action, 'connection refused'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('buildProxyTimeoutConfig normalizes invalid values to default', function (?int $input, string $expected) {
|
||||
$action = new StartDatabaseProxy;
|
||||
$method = new ReflectionMethod($action, 'buildProxyTimeoutConfig');
|
||||
|
||||
expect($method->invoke($action, $input))->toBe($expected);
|
||||
})->with([
|
||||
[null, 'proxy_timeout 3600s;'],
|
||||
[0, 'proxy_timeout 3600s;'],
|
||||
[-10, 'proxy_timeout 3600s;'],
|
||||
[120, 'proxy_timeout 120s;'],
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Test to verify that docker-compose custom start commands use the correct
|
||||
* execution context based on the preserveRepository setting.
|
||||
*
|
||||
* When preserveRepository is enabled, the compose file and .env file are
|
||||
* written to the host at /data/coolify/applications/{uuid}/. The start
|
||||
* command must run on the host (not inside the helper container) so it
|
||||
* can access these files.
|
||||
*
|
||||
* When preserveRepository is disabled, the files are inside the helper
|
||||
* container at /artifacts/{uuid}/, so the command must run inside the
|
||||
* container via executeInDocker().
|
||||
*
|
||||
* @see https://github.com/coollabsio/coolify/issues/8417
|
||||
*/
|
||||
it('generates host command (not executeInDocker) when preserveRepository is true', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$basedir = '/artifacts/test-deployment-uuid';
|
||||
$preserveRepository = true;
|
||||
|
||||
$startCommand = 'docker compose -f /data/coolify/applications/app-uuid/compose.yml --env-file /data/coolify/applications/app-uuid/.env --profile all up -d';
|
||||
|
||||
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
|
||||
if ($preserveRepository) {
|
||||
$command = "cd {$serverWorkdir} && {$startCommand}";
|
||||
} else {
|
||||
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
|
||||
}
|
||||
|
||||
// When preserveRepository is true, the command should NOT be wrapped in executeInDocker
|
||||
expect($command)->not->toContain('docker exec');
|
||||
expect($command)->toStartWith("cd {$serverWorkdir}");
|
||||
expect($command)->toContain($startCommand);
|
||||
});
|
||||
|
||||
it('generates executeInDocker command when preserveRepository is false', function () {
|
||||
$deploymentUuid = 'test-deployment-uuid';
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$basedir = '/artifacts/test-deployment-uuid';
|
||||
$workdir = '/artifacts/test-deployment-uuid/backend';
|
||||
$preserveRepository = false;
|
||||
|
||||
$startCommand = 'docker compose -f /artifacts/test-deployment-uuid/backend/compose.yml --env-file /artifacts/test-deployment-uuid/backend/.env --profile all up -d';
|
||||
|
||||
// Simulate the logic from ApplicationDeploymentJob::deploy_docker_compose_buildpack()
|
||||
if ($preserveRepository) {
|
||||
$command = "cd {$serverWorkdir} && {$startCommand}";
|
||||
} else {
|
||||
$command = executeInDocker($deploymentUuid, "cd {$basedir} && {$startCommand}");
|
||||
}
|
||||
|
||||
// When preserveRepository is false, the command SHOULD be wrapped in executeInDocker
|
||||
expect($command)->toContain('docker exec');
|
||||
expect($command)->toContain($deploymentUuid);
|
||||
expect($command)->toContain("cd {$basedir}");
|
||||
});
|
||||
|
||||
it('uses host paths for env-file when preserveRepository is true', function () {
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
$composeLocation = '/compose.yml';
|
||||
$preserveRepository = true;
|
||||
|
||||
$workdirPath = $preserveRepository ? $serverWorkdir : '/artifacts/deployment-uuid/backend';
|
||||
$startCommand = injectDockerComposeFlags(
|
||||
'docker compose --profile all up -d',
|
||||
"{$workdirPath}{$composeLocation}",
|
||||
"{$workdirPath}/.env"
|
||||
);
|
||||
|
||||
// Verify the injected paths point to the host filesystem
|
||||
expect($startCommand)->toContain("--env-file {$serverWorkdir}/.env");
|
||||
expect($startCommand)->toContain("-f {$serverWorkdir}{$composeLocation}");
|
||||
});
|
||||
|
||||
it('uses container paths for env-file when preserveRepository is false', function () {
|
||||
$workdir = '/artifacts/deployment-uuid/backend';
|
||||
$composeLocation = '/compose.yml';
|
||||
$preserveRepository = false;
|
||||
$serverWorkdir = '/data/coolify/applications/app-uuid';
|
||||
|
||||
$workdirPath = $preserveRepository ? $serverWorkdir : $workdir;
|
||||
$startCommand = injectDockerComposeFlags(
|
||||
'docker compose --profile all up -d',
|
||||
"{$workdirPath}{$composeLocation}",
|
||||
"{$workdirPath}/.env"
|
||||
);
|
||||
|
||||
// Verify the injected paths point to the container filesystem
|
||||
expect($startCommand)->toContain("--env-file {$workdir}/.env");
|
||||
expect($startCommand)->toContain("-f {$workdir}{$composeLocation}");
|
||||
expect($startCommand)->not->toContain('/data/coolify/applications/');
|
||||
});
|
||||
172
tests/Unit/Livewire/FileBrowserTest.php
Normal file
172
tests/Unit/Livewire/FileBrowserTest.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\FileBrowser;
|
||||
|
||||
test('validatePath accepts valid absolute paths', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/'))->toBeTrue();
|
||||
expect($method->invoke($component, '/home'))->toBeTrue();
|
||||
expect($method->invoke($component, '/var/log/app'))->toBeTrue();
|
||||
expect($method->invoke($component, '/usr/local/bin'))->toBeTrue();
|
||||
expect($method->invoke($component, '/tmp/my-file.txt'))->toBeTrue();
|
||||
expect($method->invoke($component, '/path/with spaces'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('validatePath rejects relative paths', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, 'relative/path'))->toBeFalse();
|
||||
expect($method->invoke($component, './current'))->toBeFalse();
|
||||
expect($method->invoke($component, 'file.txt'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects directory traversal', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/path/../etc/passwd'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/..hidden'))->toBeFalse();
|
||||
expect($method->invoke($component, '/..'))->toBeFalse();
|
||||
expect($method->invoke($component, '/../../etc'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects shell metacharacters', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, '/path/$(whoami)'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/`id`'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/;rm -rf /'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/|cat /etc/passwd'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/&bg'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/>output'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/<input'))->toBeFalse();
|
||||
expect($method->invoke($component, '/path/!history'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('validatePath rejects null bytes', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'validatePath');
|
||||
|
||||
expect($method->invoke($component, "/path/\0hidden"))->toBeFalse();
|
||||
});
|
||||
|
||||
test('parseLsOutput parses standard ls output', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 48
|
||||
drwxr-xr-x 2 root root 4096 Mar 2 12:00 config
|
||||
-rw-r--r-- 1 www www 1234 Mar 1 09:30 index.html
|
||||
-rwxr-xr-x 1 root root 567 Feb 28 15:45 start.sh
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(3);
|
||||
|
||||
expect($entries[0]['name'])->toBe('config');
|
||||
expect($entries[0]['isDirectory'])->toBeTrue();
|
||||
expect($entries[0]['permissions'])->toBe('drwxr-xr-x');
|
||||
expect($entries[0]['owner'])->toBe('root');
|
||||
|
||||
expect($entries[1]['name'])->toBe('index.html');
|
||||
expect($entries[1]['isDirectory'])->toBeFalse();
|
||||
expect($entries[1]['size'])->toBe(1234);
|
||||
expect($entries[1]['owner'])->toBe('www');
|
||||
|
||||
expect($entries[2]['name'])->toBe('start.sh');
|
||||
expect($entries[2]['permissions'])->toBe('-rwxr-xr-x');
|
||||
});
|
||||
|
||||
test('parseLsOutput sorts directories first then alphabetically', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 16
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 zebra.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 alpha
|
||||
-rw-r--r-- 1 root root 200 Mar 1 10:00 apple.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 beta
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(4);
|
||||
expect($entries[0]['name'])->toBe('alpha');
|
||||
expect($entries[0]['isDirectory'])->toBeTrue();
|
||||
expect($entries[1]['name'])->toBe('beta');
|
||||
expect($entries[1]['isDirectory'])->toBeTrue();
|
||||
expect($entries[2]['name'])->toBe('apple.txt');
|
||||
expect($entries[2]['isDirectory'])->toBeFalse();
|
||||
expect($entries[3]['name'])->toBe('zebra.txt');
|
||||
expect($entries[3]['isDirectory'])->toBeFalse();
|
||||
});
|
||||
|
||||
test('parseLsOutput skips . and .. entries', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 8
|
||||
drwxr-xr-x 3 root root 4096 Mar 1 10:00 .
|
||||
drwxr-xr-x 5 root root 4096 Mar 1 10:00 ..
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 file.txt
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(1);
|
||||
expect($entries[0]['name'])->toBe('file.txt');
|
||||
});
|
||||
|
||||
test('parseLsOutput handles symlinks', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 4
|
||||
lrwxrwxrwx 1 root root 11 Mar 1 10:00 link -> /etc/target
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 normal.txt
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(2);
|
||||
expect($entries[0]['name'])->toBe('normal.txt');
|
||||
|
||||
$symlink = collect($entries)->firstWhere('name', 'link');
|
||||
expect($symlink['isSymlink'])->toBeTrue();
|
||||
expect($symlink['linkTarget'])->toBe('/etc/target');
|
||||
});
|
||||
|
||||
test('parseLsOutput handles empty output', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
expect($method->invoke($component, ''))->toBe([]);
|
||||
expect($method->invoke($component, null))->toBe([]);
|
||||
expect($method->invoke($component, 'total 0'))->toBe([]);
|
||||
});
|
||||
|
||||
test('parseLsOutput handles files with spaces in names', function () {
|
||||
$component = new FileBrowser;
|
||||
$method = new ReflectionMethod($component, 'parseLsOutput');
|
||||
|
||||
$output = <<<'LS'
|
||||
total 4
|
||||
-rw-r--r-- 1 root root 100 Mar 1 10:00 my file name.txt
|
||||
drwxr-xr-x 2 root root 4096 Mar 1 10:00 my folder
|
||||
LS;
|
||||
|
||||
$entries = $method->invoke($component, $output);
|
||||
|
||||
expect($entries)->toHaveCount(2);
|
||||
expect(collect($entries)->firstWhere('isDirectory', true)['name'])->toBe('my folder');
|
||||
expect(collect($entries)->firstWhere('isDirectory', false)['name'])->toBe('my file name.txt');
|
||||
});
|
||||
11
tests/Unit/ServiceIndexValidationTest.php
Normal file
11
tests/Unit/ServiceIndexValidationTest.php
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
use App\Livewire\Project\Service\Index;
|
||||
|
||||
test('service database proxy timeout requires a minimum of one second', function () {
|
||||
$component = new Index;
|
||||
$rules = (fn (): array => $this->rules)->call($component);
|
||||
|
||||
expect($rules['publicPortTimeout'])
|
||||
->toContain('min:1');
|
||||
});
|
||||
Loading…
Reference in a new issue