Compare commits

...

22 commits

Author SHA1 Message Date
Deducer
e76e18cf71
Merge ffacd18100 into 6bcae50e49 2026-03-10 11:36:08 +01:00
Andras Bacsai
6bcae50e49
fix(database): close confirmation modal after database import/restore (#8697) 2026-03-10 10:38:22 +01:00
Andras Bacsai
db55c8160a Merge remote-tracking branch 'origin/next' into fix/database-import-modal-not-closing-v2 2026-03-10 10:38:10 +01:00
Andras Bacsai
60dfadf036
feat: add configurable proxy timeout for public database TCP proxy (#8673) 2026-03-10 10:08:35 +01:00
Andras Bacsai
27e2680d70 Merge remote-tracking branch 'origin/next' into fix/configurable-proxy-timeout 2026-03-10 10:01:46 +01:00
Andras Bacsai
65d61a4af3
fix(proxy): mounting error for nginx.conf in dev (#8662) 2026-03-10 10:01:33 +01:00
Andras Bacsai
b5151815c1 Merge remote-tracking branch 'origin/next' into fix/dev-dbproxy 2026-03-10 10:01:14 +01:00
Andras Bacsai
184fbb98f3 fix(proxy): add validation and normalization for database proxy timeout
- Extract proxy timeout configuration logic into dedicated method
- Add min:1 validation rule for publicPortTimeout
- Normalize invalid timeout values (null, 0, negative) to default 3600s
- Add tests for timeout configuration normalization and validation
2026-03-10 09:59:19 +01:00
Andras Bacsai
a5367408d0
fix(docker-compose): respect preserveRepository setting when executing start command (#8848) 2026-03-10 09:45:43 +01:00
Andras Bacsai
574f849778
fix: enable preview deployment page for deploy key applications (#8579) 2026-03-10 09:45:24 +01:00
Andras Bacsai
19d1662fac Merge remote-tracking branch 'origin/next' into fix/preview-deployments-invisible 2026-03-10 09:44:31 +01:00
Andras Bacsai
e3daba0b1d chore: prepare for PR 2026-03-10 09:43:29 +01:00
Andras Bacsai
7bee8a5668 Merge remote-tracking branch 'origin/next' into fix/database-import-modal-not-closing-v2 2026-03-06 08:04:07 +01:00
Andras Bacsai
4615cfd007 Merge remote-tracking branch 'origin/next' into fix/configurable-proxy-timeout 2026-03-06 08:04:07 +01:00
Andras Bacsai
31caef990d Merge remote-tracking branch 'origin/next' into fix/dev-dbproxy 2026-03-06 08:04:06 +01:00
Andras Bacsai
380a34c7d6 Merge remote-tracking branch 'origin/next' into fix/preview-deployments-invisible 2026-03-06 08:03:45 +01:00
Ian Cross
ffacd18100 feat: add container file browser for applications, databases, and services
Adds a file browser allowing users to browse, upload, download,
and manage files inside running containers. Accessible from the
Files tab behind the canAccessTerminal gate.

Closes #6519
2026-03-02 13:02:40 -07:00
Devrim Tunçer
cc96403cbe fix(database): close confirmation modal after import/restore
The modal stayed open because runImport() and restoreFromS3() did not
accept the password parameter, verify it, or return true on success.

Added password verification and return values to both methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:45:55 +03:00
Brendan G. Lim
040658c142 fix: address review feedback on proxy timeout
- Fix disable logic: timeout editable when proxy is stopped
- Remove hardcoded proxy_connect_timeout (60s is nginx default)
- Remove misleading '0 for no timeout' helper text
- Add min:1 validation for timeout value
2026-02-27 14:24:04 -08:00
Cinzya
34c5eb9e10 fix(proxy): mounting error for nginx.conf in dev 2026-02-27 22:07:37 +01:00
Brendan G. Lim
30c1d9bbd0 feat: add configurable timeout for public database TCP proxy
Adds a per-database 'Proxy Timeout' setting for publicly exposed databases.
The nginx stream proxy_timeout can now be configured in the UI, defaulting
to 3600s (1 hour) instead of nginx's 10min default. Set to 0 for no timeout.

Fixes #7743
2026-02-26 21:12:58 -08:00
Maurits de Ruiter
8cc10ab10a
fix: enable preview deployment page for deploy key applications 2026-02-23 21:08:43 +01:00
42 changed files with 1257 additions and 22 deletions

View file

@ -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;";
}
}

View file

@ -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) {

View file

@ -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]);

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;
}

View 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');
}
}

View file

@ -11,6 +11,10 @@ class ServiceDatabase extends BaseModel
protected $guarded = [];
protected $casts = [
'public_port_timeout' => 'integer',
];
protected static function booted()
{
static::deleting(function ($service) {

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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',

View file

@ -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');
});
}
}
}
};

View file

@ -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

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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' ||

View file

@ -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>"

View file

@ -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" />

View file

@ -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" />

View file

@ -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>

View file

@ -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">

View file

@ -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

View file

@ -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>

View 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'])
&mdash;
@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">-&gt; {{ $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>

View file

@ -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');

View file

@ -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;'],
]);

View file

@ -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/');
});

View 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');
});

View 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');
});