diff --git a/README.md b/README.md
index 9a5feff4e..7a3f2a65e 100644
--- a/README.md
+++ b/README.md
@@ -60,7 +60,7 @@ ### Huge Sponsors
* [MVPS](https://www.mvps.net?ref=coolify.io) - Cheap VPS servers at the highest possible quality
* [SerpAPI](https://serpapi.com?ref=coolify.io) - Google Search API — Scrape Google and other search engines from our fast, easy, and complete API
* [ScreenshotOne](https://screenshotone.com?ref=coolify.io) - Screenshot API for devs
-*
+* [PrivateAlps](https://privatealps.net?ref=coolify.io) - Cloud Services Provider, VPS, servers Infrastructure for people who care about privacy and control
### Big Sponsors
@@ -151,6 +151,10 @@ ### Small Sponsors
+
+
+
+
...and many more at [GitHub Sponsors](https://github.com/sponsors/coollabsio)
diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php
index 393906b9b..30cae71f1 100644
--- a/app/Actions/Database/StartClickhouse.php
+++ b/app/Actions/Database/StartClickhouse.php
@@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
+ 'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php
index cd820523d..addc30be4 100644
--- a/app/Actions/Database/StartDragonfly.php
+++ b/app/Actions/Database/StartDragonfly.php
@@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
+ 'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php
index fe80a7d54..e59d6f697 100644
--- a/app/Actions/Database/StartKeydb.php
+++ b/app/Actions/Database/StartKeydb.php
@@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
+ 'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database)
$docker_compose['volumes'] = $volume_names;
}
- if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
+ if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php
index 498ba0b0b..ceb1e8b85 100644
--- a/app/Actions/Database/StartMariadb.php
+++ b/app/Actions/Database/StartMariadb.php
@@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database)
);
}
- if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
+ if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'],
[
diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php
index 9565990c1..c79789718 100644
--- a/app/Actions/Database/StartMongodb.php
+++ b/app/Actions/Database/StartMongodb.php
@@ -340,7 +340,10 @@ private function add_custom_mongo_conf()
private function add_default_database()
{
- $content = "db = db.getSiblingDB(\"{$this->database->mongo_initdb_database}\");db.createCollection('init_collection');db.createUser({user: \"{$this->database->mongo_initdb_root_username}\", pwd: \"{$this->database->mongo_initdb_root_password}\",roles: [{role:\"readWrite\",db:\"{$this->database->mongo_initdb_database}\"}]});";
+ $dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
+ $userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
+ $pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
+ $content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
$content_base64 = base64_encode($content);
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
$this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/01-default-database.js > /dev/null";
diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php
index 337516405..0394d50b6 100644
--- a/app/Actions/Database/StartMysql.php
+++ b/app/Actions/Database/StartMysql.php
@@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database)
);
}
- if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
+ if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
$docker_compose['services'][$container_name]['volumes'] = array_merge(
$docker_compose['services'][$container_name]['volumes'] ?? [],
[
@@ -215,7 +215,8 @@ public function handle(StandaloneMysql $database)
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->mysql_user}:{$this->database->mysql_user} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
+ $mysqlUser = escapeshellarg($this->database->mysql_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$mysqlUser}:{$mysqlUser} /etc/mysql/certs/server.crt /etc/mysql/certs/server.key");
}
$this->commands[] = "echo 'Database started.'";
diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php
index 41e39c811..da8b5dc4e 100644
--- a/app/Actions/Database/StartPostgresql.php
+++ b/app/Actions/Database/StartPostgresql.php
@@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database)
],
'labels' => defaultDatabaseLabels($this->database)->toArray(),
'healthcheck' => [
- 'test' => [
- 'CMD-SHELL',
- "psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
- ],
+ 'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
'interval' => '5s',
'timeout' => '5s',
'retries' => 10,
@@ -227,7 +224,8 @@ public function handle(StandalonePostgresql $database)
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
- $this->commands[] = executeInDocker($this->database->uuid, "chown {$this->database->postgres_user}:{$this->database->postgres_user} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
+ $postgresUser = escapeshellarg($this->database->postgres_user);
+ $this->commands[] = executeInDocker($this->database->uuid, "chown {$postgresUser}:{$postgresUser} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
}
$this->commands[] = "echo 'Database started.'";
@@ -304,9 +302,18 @@ private function generate_init_scripts()
foreach ($this->database->init_scripts as $init_script) {
$filename = data_get($init_script, 'filename');
$content = data_get($init_script, 'content');
+
+ // Normalise filename without rejecting legacy values so previously created
+ // init scripts keep deploying. basename() strips any directory components
+ // (path traversal) and escapeshellarg() contains every shell metacharacter
+ // in the tee target. Livewire / API validate new filenames up front.
+ $filename = basename((string) $filename);
+
+ $target_path = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $escaped_target = escapeshellarg($target_path);
$content_base64 = base64_encode($content);
- $this->commands[] = "echo '{$content_base64}' | base64 -d | tee $this->configuration_dir/docker-entrypoint-initdb.d/{$filename} > /dev/null";
- $this->init_scripts[] = "$this->configuration_dir/docker-entrypoint-initdb.d/{$filename}";
+ $this->commands[] = "echo '{$content_base64}' | base64 -d | tee {$escaped_target} > /dev/null";
+ $this->init_scripts[] = $target_path;
}
}
diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php
index 70df91054..c31b099e4 100644
--- a/app/Actions/Database/StartRedis.php
+++ b/app/Actions/Database/StartRedis.php
@@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database)
);
}
- if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
+ if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$docker_compose['services'][$container_name]['volumes'][] = [
'type' => 'bind',
'source' => $this->configuration_dir.'/redis.conf',
diff --git a/app/Actions/Server/CleanupDocker.php b/app/Actions/Server/CleanupDocker.php
index 135623b1f..33558c746 100644
--- a/app/Actions/Server/CleanupDocker.php
+++ b/app/Actions/Server/CleanupDocker.php
@@ -48,7 +48,7 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
);
$commands = [
- 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
+ 'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
diff --git a/app/Console/Commands/ViewScheduledLogs.php b/app/Console/Commands/ViewScheduledLogs.php
index 9ecf90716..b6e9a6121 100644
--- a/app/Console/Commands/ViewScheduledLogs.php
+++ b/app/Console/Commands/ViewScheduledLogs.php
@@ -28,6 +28,11 @@ class ViewScheduledLogs extends Command
public function handle()
{
$date = $this->option('date') ?: now()->format('Y-m-d');
+ if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
+ $this->error('Invalid date format. Use Y-m-d (e.g. 2025-01-31).');
+
+ return self::INVALID;
+ }
$logPaths = $this->getLogPaths($date);
if (empty($logPaths)) {
@@ -49,17 +54,19 @@ public function handle()
$this->line('');
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -f {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPath} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPath}");
}
} else {
// Multiple files - use multitail or tail with process substitution
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -f {$logPathsStr} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -f {$logPathsStr} | grep -E {$escapedFilters}");
} else {
passthru("tail -f {$logPathsStr}");
}
@@ -68,20 +75,23 @@ public function handle()
$this->info("Showing last {$lines} lines of {$logTypeDescription} logs for {$date}{$filterDescription}:");
$this->line('');
+ $escapedLines = escapeshellarg((string) $lines);
if (count($logPaths) === 1) {
- $logPath = $logPaths[0];
+ $logPath = escapeshellarg($logPaths[0]);
if ($filters) {
- passthru("tail -n {$lines} {$logPath} | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPath} | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPath}");
+ passthru("tail -n {$escapedLines} {$logPath}");
}
} else {
// Multiple files - concatenate and sort by timestamp
- $logPathsStr = implode(' ', $logPaths);
+ $logPathsStr = implode(' ', array_map('escapeshellarg', $logPaths));
if ($filters) {
- passthru("tail -n {$lines} {$logPathsStr} | sort | grep -E '{$filters}'");
+ $escapedFilters = escapeshellarg($filters);
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort | grep -E {$escapedFilters}");
} else {
- passthru("tail -n {$lines} {$logPathsStr} | sort");
+ passthru("tail -n {$escapedLines} {$logPathsStr} | sort");
}
}
}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index c5e12b7ee..75ec31ae0 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -2,6 +2,7 @@
namespace App\Console;
+use App\Jobs\ApiTokenExpirationWarningJob;
use App\Jobs\CheckForUpdatesJob;
use App\Jobs\CheckHelperImageJob;
use App\Jobs\CheckTraefikVersionJob;
@@ -41,6 +42,8 @@ protected function schedule(Schedule $schedule): void
// $this->scheduleInstance->job(new CleanupStaleMultiplexedConnections)->hourly();
$this->scheduleInstance->command('cleanup:redis --clear-locks')->daily();
+ $this->scheduleInstance->command('sanctum:prune-expired --hours=1')->hourly()->onOneServer();
+ $this->scheduleInstance->job(new ApiTokenExpirationWarningJob)->hourly()->onOneServer();
if (isDev()) {
// Instance Jobs
diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php
index 8da4f82df..d8834f995 100644
--- a/app/Http/Controllers/Api/ApplicationsController.php
+++ b/app/Http/Controllers/Api/ApplicationsController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api;
+use App\Actions\Application\CleanupPreviewDeployment;
use App\Actions\Application\LoadComposeFile;
use App\Actions\Application\StopApplication;
use App\Actions\Service\StartService;
@@ -9,6 +10,7 @@
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
+use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -1058,7 +1060,7 @@ private function create_application(Request $request, $type)
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
- $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
+ $isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -4119,7 +4121,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
@@ -4297,7 +4299,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -4474,4 +4476,73 @@ public function delete_storage(Request $request): JsonResponse
return response()->json(['message' => 'Storage deleted.']);
}
+
+ #[OA\Delete(
+ summary: 'Delete Preview Deployment',
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.',
+ path: '/applications/{uuid}/previews/{pull_request_id}',
+ operationId: 'delete-preview-deployment-by-pull-request-id',
+ security: [
+ ['bearerAuth' => []],
+ ],
+ tags: ['Applications'],
+ parameters: [
+ new OA\Parameter(
+ name: 'uuid',
+ in: 'path',
+ description: 'UUID of the application.',
+ required: true,
+ schema: new OA\Schema(type: 'string')
+ ),
+ new OA\Parameter(
+ name: 'pull_request_id',
+ in: 'path',
+ description: 'Pull request ID of the preview to delete.',
+ required: true,
+ schema: new OA\Schema(type: 'integer')
+ ),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'Preview deletion queued.', content: new OA\JsonContent(
+ properties: [new OA\Property(property: 'message', type: 'string')],
+ )),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 400, ref: '#/components/responses/400'),
+ new OA\Response(response: 404, ref: '#/components/responses/404'),
+ new OA\Response(response: 422, ref: '#/components/responses/422'),
+ ]
+ )]
+ public function delete_preview_by_pull_request_id(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
+ }
+
+ $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
+ if (! $application) {
+ return response()->json(['message' => 'Application not found.'], 404);
+ }
+
+ $this->authorize('delete', $application);
+
+ $pullRequestIdRaw = $request->route('pull_request_id');
+ if (! is_numeric($pullRequestIdRaw) || (int) $pullRequestIdRaw <= 0) {
+ return response()->json(['message' => 'Invalid pull_request_id.'], 422);
+ }
+ $pullRequestId = (int) $pullRequestIdRaw;
+
+ $preview = ApplicationPreview::where('application_id', $application->id)
+ ->where('pull_request_id', $pullRequestId)
+ ->first();
+
+ if (! $preview) {
+ return response()->json(['message' => 'Preview not found.'], 404);
+ }
+
+ $preview->delete();
+ CleanupPreviewDeployment::run($application, $pullRequestId, $preview);
+
+ return response()->json(['message' => 'Preview deletion request queued.']);
+ }
}
diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php
index 8e31a7051..a6056b9f3 100644
--- a/app/Http/Controllers/Api/DatabasesController.php
+++ b/app/Http/Controllers/Api/DatabasesController.php
@@ -379,9 +379,9 @@ public function update_by_uuid(Request $request)
case 'standalone-postgresql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
- 'postgres_user' => 'string',
- 'postgres_password' => 'string',
- 'postgres_db' => 'string',
+ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@@ -410,20 +410,20 @@ public function update_by_uuid(Request $request)
case 'standalone-clickhouse':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
- 'clickhouse_admin_user' => 'string',
- 'clickhouse_admin_password' => 'string',
+ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
break;
case 'standalone-dragonfly':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
- 'dragonfly_password' => 'string',
+ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
break;
case 'standalone-redis':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
- 'redis_password' => 'string',
+ 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
if ($request->has('redis_conf')) {
@@ -450,7 +450,7 @@ public function update_by_uuid(Request $request)
case 'standalone-keydb':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
- 'keydb_password' => 'string',
+ 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
if ($request->has('keydb_conf')) {
@@ -478,10 +478,10 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
'mariadb_conf' => 'string',
- 'mariadb_root_password' => 'string',
- 'mariadb_user' => 'string',
- 'mariadb_password' => 'string',
- 'mariadb_database' => 'string',
+ 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mariadb_conf')) {
if (! isBase64Encoded($request->mariadb_conf)) {
@@ -508,9 +508,9 @@ public function update_by_uuid(Request $request)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
- 'mongo_initdb_root_username' => 'string',
- 'mongo_initdb_root_password' => 'string',
- 'mongo_initdb_database' => 'string',
+ 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
if ($request->has('mongo_conf')) {
if (! isBase64Encoded($request->mongo_conf)) {
@@ -537,10 +537,10 @@ public function update_by_uuid(Request $request)
case 'standalone-mysql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
- 'mysql_root_password' => 'string',
- 'mysql_password' => 'string',
- 'mysql_user' => 'string',
- 'mysql_database' => 'string',
+ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
if ($request->has('mysql_conf')) {
@@ -639,10 +639,10 @@ public function update_by_uuid(Request $request)
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +703,10 @@ public function create_backup(Request $request)
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -747,7 +747,7 @@ public function create_backup(Request $request)
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -774,7 +774,7 @@ public function create_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -878,10 +878,10 @@ public function create_backup(Request $request)
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
- 'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
+ 'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
- 'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
+ 'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +933,10 @@ public function update_backup(Request $request)
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
- 'database_backup_retention_max_storage_locally' => 'integer|min:0',
+ 'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
- 'database_backup_retention_max_storage_s3' => 'integer|min:0',
+ 'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -982,7 +982,7 @@ public function update_backup(Request $request)
], 422);
}
if ($request->filled('s3_storage_uuid')) {
- $existsInTeam = S3Storage::ownedByCurrentTeam()->where('uuid', $request->s3_storage_uuid)->exists();
+ $existsInTeam = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->s3_storage_uuid)->exists();
if (! $existsInTeam) {
return response()->json([
'message' => 'Validation failed.',
@@ -1015,7 +1015,7 @@ public function update_backup(Request $request)
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
- $s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
+ $s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -1724,9 +1724,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
if ($type === NewDatabaseTypes::POSTGRESQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'postgres_user', 'postgres_password', 'postgres_db', 'postgres_initdb_args', 'postgres_host_auth_method', 'postgres_conf'];
$validator = customApiValidator($request->all(), [
- 'postgres_user' => 'string',
- 'postgres_password' => 'string',
- 'postgres_db' => 'string',
+ 'postgres_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'postgres_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'postgres_db' => ValidationPatterns::databaseIdentifierRules(required: false),
'postgres_initdb_args' => 'string',
'postgres_host_auth_method' => 'string',
'postgres_conf' => 'string',
@@ -1766,7 +1766,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('postgres_conf', $postgresConf);
}
- $database = create_standalone_postgresql($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_postgresql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1783,8 +1783,11 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::MARIADB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mariadb_conf', 'mariadb_root_password', 'mariadb_user', 'mariadb_password', 'mariadb_database'];
$validator = customApiValidator($request->all(), [
- 'clickhouse_admin_user' => 'string',
- 'clickhouse_admin_password' => 'string',
+ 'mariadb_conf' => 'string',
+ 'mariadb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mariadb_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mariadb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -1821,7 +1824,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mariadb_conf', $mariadbConf);
}
- $database = create_standalone_mariadb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mariadb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1839,10 +1842,10 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::MYSQL) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
- 'mysql_root_password' => 'string',
- 'mysql_password' => 'string',
- 'mysql_user' => 'string',
- 'mysql_database' => 'string',
+ 'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1880,7 +1883,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mysql_conf', $mysqlConf);
}
- $database = create_standalone_mysql($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mysql($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1898,7 +1901,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::REDIS) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'redis_password', 'redis_conf'];
$validator = customApiValidator($request->all(), [
- 'redis_password' => 'string',
+ 'redis_password' => ValidationPatterns::databasePasswordRules(required: false),
'redis_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1936,7 +1939,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('redis_conf', $redisConf);
}
- $database = create_standalone_redis($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_redis($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1954,7 +1957,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::DRAGONFLY) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'dragonfly_password'];
$validator = customApiValidator($request->all(), [
- 'dragonfly_password' => 'string',
+ 'dragonfly_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -1973,7 +1976,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_dragonfly($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_dragonfly($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -1984,7 +1987,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::KEYDB) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'keydb_password', 'keydb_conf'];
$validator = customApiValidator($request->all(), [
- 'keydb_password' => 'string',
+ 'keydb_password' => ValidationPatterns::databasePasswordRules(required: false),
'keydb_conf' => 'string',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
@@ -2022,7 +2025,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('keydb_conf', $keydbConf);
}
- $database = create_standalone_keydb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_keydb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2040,8 +2043,8 @@ public function create_database(Request $request, NewDatabaseTypes $type)
} elseif ($type === NewDatabaseTypes::CLICKHOUSE) {
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'clickhouse_admin_user', 'clickhouse_admin_password'];
$validator = customApiValidator($request->all(), [
- 'clickhouse_admin_user' => 'string',
- 'clickhouse_admin_password' => 'string',
+ 'clickhouse_admin_user' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'clickhouse_admin_password' => ValidationPatterns::databasePasswordRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -2058,7 +2061,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
], 422);
}
removeUnnecessaryFieldsFromRequest($request);
- $database = create_standalone_clickhouse($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_clickhouse($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2077,9 +2080,9 @@ public function create_database(Request $request, NewDatabaseTypes $type)
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mongo_conf', 'mongo_initdb_root_username', 'mongo_initdb_root_password', 'mongo_initdb_database'];
$validator = customApiValidator($request->all(), [
'mongo_conf' => 'string',
- 'mongo_initdb_root_username' => 'string',
- 'mongo_initdb_root_password' => 'string',
- 'mongo_initdb_database' => 'string',
+ 'mongo_initdb_root_username' => ValidationPatterns::databaseIdentifierRules(required: false),
+ 'mongo_initdb_root_password' => ValidationPatterns::databasePasswordRules(required: false),
+ 'mongo_initdb_database' => ValidationPatterns::databaseIdentifierRules(required: false),
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
@@ -2116,7 +2119,7 @@ public function create_database(Request $request, NewDatabaseTypes $type)
}
$request->offsetSet('mongo_conf', $mongoConf);
}
- $database = create_standalone_mongodb($environment->id, $destination->uuid, $request->only($allowedFields));
+ $database = create_standalone_mongodb($environment->id, $destination, $request->only($allowedFields));
if ($instantDeploy) {
StartDatabase::dispatch($database);
}
@@ -2332,7 +2335,7 @@ public function delete_backup_by_uuid(Request $request)
} catch (\Exception $e) {
DB::rollBack();
- return response()->json(['message' => 'Failed to delete backup: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup.'], 500);
}
}
@@ -2452,7 +2455,7 @@ public function delete_execution_by_uuid(Request $request)
'message' => 'Backup execution deleted.',
]);
} catch (\Exception $e) {
- return response()->json(['message' => 'Failed to delete backup execution: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to delete backup execution.'], 500);
}
}
@@ -3496,7 +3499,7 @@ public function create_storage(Request $request): JsonResponse
'type' => 'required|string|in:persistent,file',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -3694,7 +3697,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php
index ed91b4475..092c48594 100644
--- a/app/Http/Controllers/Api/HetznerController.php
+++ b/app/Http/Controllers/Api/HetznerController.php
@@ -121,7 +121,7 @@ public function locations(Request $request)
return response()->json($locations);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
}
}
@@ -242,7 +242,7 @@ public function serverTypes(Request $request)
return response()->json($serverTypes);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
}
}
@@ -354,7 +354,7 @@ public function images(Request $request)
return response()->json(array_values($filtered));
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
}
}
@@ -450,7 +450,7 @@ public function sshKeys(Request $request)
return response()->json($sshKeys);
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
}
}
@@ -733,7 +733,7 @@ public function createServer(Request $request)
return $response;
} catch (\Throwable $e) {
- return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
+ return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
}
}
}
diff --git a/app/Http/Controllers/Api/OtherController.php b/app/Http/Controllers/Api/OtherController.php
index 8f2ba25c8..49468b597 100644
--- a/app/Http/Controllers/Api/OtherController.php
+++ b/app/Http/Controllers/Api/OtherController.php
@@ -147,11 +147,15 @@ public function disable_api(Request $request)
public function feedback(Request $request)
{
- $content = $request->input('content');
+ $data = $request->validate([
+ 'content' => ['required', 'string', 'min:10', 'max:2000'],
+ ]);
+
$webhook_url = config('constants.webhooks.feedback_discord_webhook');
if ($webhook_url) {
- Http::post($webhook_url, [
- 'content' => $content,
+ Http::timeout(5)->post($webhook_url, [
+ 'content' => $data['content'],
+ 'allowed_mentions' => ['parse' => []],
]);
}
diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php
index 23ba30998..20560635e 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -2018,7 +2018,7 @@ public function create_storage(Request $request): JsonResponse
'resource_uuid' => 'required|string',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'required|string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'fs_path' => 'string',
@@ -2227,7 +2227,7 @@ public function update_storage(Request $request): JsonResponse
'is_preview_suffix_enabled' => 'boolean',
'name' => ['string', 'regex:'.ValidationPatterns::VOLUME_NAME_PATTERN],
'mount_path' => 'string',
- 'host_path' => 'string|nullable',
+ 'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
]);
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 17d14296b..6ce6b6d57 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -6,8 +6,8 @@
use App\Models\TeamInvitation;
use App\Models\User;
use App\Providers\RouteServiceProvider;
+use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
-use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
@@ -39,9 +39,29 @@ public function verify()
return view('auth.verify-email');
}
- public function email_verify(EmailVerificationRequest $request)
+ public function email_verify(Request $request)
{
- $request->fulfill();
+ if (! $request->hasValidSignature()) {
+ abort(403);
+ }
+
+ $user = auth()->user();
+ if (! $user) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('id'), (string) $user->getKey())) {
+ abort(403);
+ }
+
+ if (! hash_equals((string) $request->route('hash'), hash('sha256', $user->getEmailForVerification()))) {
+ abort(403);
+ }
+
+ if (! $user->hasVerifiedEmail()) {
+ $user->markEmailAsVerified();
+ event(new Verified($user));
+ }
return redirect(RouteServiceProvider::HOME);
}
@@ -94,10 +114,6 @@ public function link()
} else {
$team = $user->teams()->first();
}
- if (is_null(data_get($user, 'email_verified_at'))) {
- $user->email_verified_at = now();
- $user->save();
- }
Auth::login($user);
session(['currentTeam' => $team]);
diff --git a/app/Http/Controllers/OauthController.php b/app/Http/Controllers/OauthController.php
index 3a3f18c9c..4038fe63e 100644
--- a/app/Http/Controllers/OauthController.php
+++ b/app/Http/Controllers/OauthController.php
@@ -19,7 +19,12 @@ public function callback(string $provider)
{
try {
$oauthUser = get_socialite_provider($provider)->user();
- $user = User::whereEmail($oauthUser->email)->first();
+ $email = trim((string) $oauthUser->email);
+ if ($email === '') {
+ abort(403, 'OAuth provider did not return an email address');
+ }
+ $email = strtolower($email);
+ $user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ public function callback(string $provider)
$user = User::create([
'name' => $oauthUser->name,
- 'email' => $oauthUser->email,
+ 'email' => $email,
]);
}
Auth::login($user);
diff --git a/app/Http/Controllers/UploadController.php b/app/Http/Controllers/UploadController.php
index 93847589a..96fbd7193 100644
--- a/app/Http/Controllers/UploadController.php
+++ b/app/Http/Controllers/UploadController.php
@@ -11,6 +11,26 @@
class UploadController extends BaseController
{
+ private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
+
+ private const ALLOWED_EXTENSIONS = [
+ 'sql',
+ 'sql.gz',
+ 'gz',
+ 'zip',
+ 'tar',
+ 'tar.gz',
+ 'tgz',
+ 'dump',
+ 'bak',
+ 'bson',
+ 'bson.gz',
+ 'archive',
+ 'archive.gz',
+ 'bz2',
+ 'xz',
+ ];
+
public function upload(Request $request)
{
$databaseIdentifier = request()->route('databaseUuid');
@@ -18,6 +38,22 @@ public function upload(Request $request)
if (is_null($resource)) {
return response()->json(['error' => 'You do not have permission for this database'], 500);
}
+
+ $chunk = $request->file('file');
+ $originalName = $chunk instanceof UploadedFile ? $chunk->getClientOriginalName() : null;
+ if (blank($originalName) || ! self::hasAllowedExtension($originalName)) {
+ return response()->json([
+ 'error' => 'Unsupported file type. Allowed extensions: '.implode(', ', self::ALLOWED_EXTENSIONS),
+ ], 422);
+ }
+
+ $declaredTotalSize = (int) $request->input('dzTotalFilesize', 0);
+ if ($declaredTotalSize > self::MAX_BYTES) {
+ return response()->json([
+ 'error' => 'File exceeds maximum allowed size of '.self::formatMaxSize().'.',
+ ], 422);
+ }
+
$receiver = new FileReceiver('file', $request, HandlerFactory::classFromRequest($request));
if ($receiver->isUploaded() === false) {
@@ -40,29 +76,20 @@ public function upload(Request $request)
'status' => true,
]);
}
- // protected function saveFileToS3($file)
- // {
- // $fileName = $this->createFilename($file);
- // $disk = Storage::disk('s3');
- // // It's better to use streaming Streaming (laravel 5.4+)
- // $disk->putFileAs('photos', $file, $fileName);
-
- // // for older laravel
- // // $disk->put($fileName, file_get_contents($file), 'public');
- // $mime = str_replace('/', '-', $file->getMimeType());
-
- // // We need to delete the file when uploaded to s3
- // unlink($file->getPathname());
-
- // return response()->json([
- // 'path' => $disk->url($fileName),
- // 'name' => $fileName,
- // 'mime_type' => $mime
- // ]);
- // }
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
+ $originalName = $file->getClientOriginalName();
+ $size = $file->getSize();
+
+ if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
+ @unlink($file->getPathname());
+
+ return response()->json([
+ 'error' => 'Uploaded file failed validation.',
+ ], 422);
+ }
+
$mime = str_replace('/', '-', $file->getMimeType());
$filePath = "upload/{$resourceIdentifier}";
$finalPath = storage_path('app/'.$filePath);
@@ -73,13 +100,30 @@ protected function saveFile(UploadedFile $file, string $resourceIdentifier)
]);
}
- protected function createFilename(UploadedFile $file)
+ private static function hasAllowedExtension(string $name): bool
{
- $extension = $file->getClientOriginalExtension();
- $filename = str_replace('.'.$extension, '', $file->getClientOriginalName()); // Filename without extension
+ $lower = strtolower($name);
+ $suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
+ usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
- $filename .= '_'.md5(time()).'.'.$extension;
+ foreach ($suffixes as $suffix) {
+ if (! str_ends_with($lower, $suffix)) {
+ continue;
+ }
- return $filename;
+ $stem = substr($lower, 0, -strlen($suffix));
+ if ($stem !== '' && ! str_ends_with($stem, '.')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ return false;
+ }
+
+ private static function formatMaxSize(): string
+ {
+ return (self::MAX_BYTES / (1024 * 1024 * 1024)).' GiB';
}
}
diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index 183186711..ffa71b55a 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -57,10 +57,29 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_bitbucket');
+ if (empty($webhook_secret)) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'Webhook secret not configured.',
+ ]);
+
+ continue;
+ }
$payload = $request->getContent();
- [$algo, $hash] = explode('=', $x_bitbucket_token, 2);
- $payloadHash = hash_hmac($algo, $payload, $webhook_secret);
+ $parts = explode('=', $x_bitbucket_token, 2);
+ if (count($parts) !== 2 || $parts[0] !== 'sha256') {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'Invalid signature.',
+ ]);
+
+ continue;
+ }
+ $hash = $parts[1];
+ $payloadHash = hash_hmac('sha256', $payload, $webhook_secret);
if (! hash_equals($hash, $payloadHash) && ! isDev()) {
$return_payloads->push([
'application' => $application->name,
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index a9d65eae6..62adf5410 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -67,6 +67,15 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitea');
+ if (empty($webhook_secret)) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'Webhook secret not configured.',
+ ]);
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index fe49369ea..4158016d0 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -81,6 +81,15 @@ public function manual(Request $request)
foreach ($applicationsByServer as $serverId => $serverApplications) {
foreach ($serverApplications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_github');
+ if (empty($webhook_secret)) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'Webhook secret not configured.',
+ ]);
+
+ continue;
+ }
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (! hash_equals($x_hub_signature_256, $hmac) && ! isDev()) {
$return_payloads->push([
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 08e5d7162..4453a0e7a 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -100,7 +100,16 @@ public function manual(Request $request)
}
foreach ($applications as $application) {
$webhook_secret = data_get($application, 'manual_webhook_secret_gitlab');
- if (! hash_equals($webhook_secret ?? '', $x_gitlab_token ?? '')) {
+ if (empty($webhook_secret)) {
+ $return_payloads->push([
+ 'application' => $application->name,
+ 'status' => 'failed',
+ 'message' => 'Webhook secret not configured.',
+ ]);
+
+ continue;
+ }
+ if (! hash_equals($webhook_secret, $x_gitlab_token ?? '')) {
$return_payloads->push([
'application' => $application->name,
'status' => 'failed',
diff --git a/app/Jobs/ApiTokenExpirationWarningJob.php b/app/Jobs/ApiTokenExpirationWarningJob.php
new file mode 100644
index 000000000..a8f388c85
--- /dev/null
+++ b/app/Jobs/ApiTokenExpirationWarningJob.php
@@ -0,0 +1,49 @@
+whereNotNull('expires_at')
+ ->where('expires_at', '>', now())
+ ->where('expires_at', '<=', now()->addDay())
+ ->where('tokenable_type', User::class)
+ ->chunkById(100, function ($tokens) {
+ foreach ($tokens as $token) {
+ if (! $token->team_id) {
+ continue;
+ }
+ RateLimiter::attempt(
+ 'api-token-expiring:'.$token->id,
+ $maxAttempts = 0,
+ function () use ($token) {
+ Team::find($token->team_id)?->notify(new ApiTokenExpiringNotification($token));
+ },
+ $decaySeconds = 7 * 24 * 3600,
+ );
+ }
+ });
+ }
+}
diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php
index f328274ac..e12c8eabf 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -3396,29 +3396,28 @@ private function build_image()
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
- $safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
- $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
- $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
+ $build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php
index f37a9704e..060ec3ac6 100644
--- a/app/Jobs/VolumeCloneJob.php
+++ b/app/Jobs/VolumeCloneJob.php
@@ -43,27 +43,34 @@ public function handle()
protected function cloneLocalVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
+
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->sourceVolume:/source -v $this->targetVolume:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
], $this->sourceServer);
}
protected function cloneRemoteVolume()
{
+ $srcVol = escapeshellarg($this->sourceVolume);
+ $tgtVol = escapeshellarg($this->targetVolume);
$sourceCloneDir = "{$this->cloneDir}/{$this->sourceVolume}";
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
+ $srcDir = escapeshellarg($sourceCloneDir);
+ $tgtDir = escapeshellarg($targetCloneDir);
try {
instant_remote_process([
- "mkdir -p $sourceCloneDir",
- "chmod 777 $sourceCloneDir",
- "docker run --rm -v $this->sourceVolume:/source -v $sourceCloneDir:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
+ "mkdir -p {$srcDir}",
+ "chmod 777 {$srcDir}",
+ "docker run --rm -v {$srcVol}:/source -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/volume-data.tar.gz .'",
], $this->sourceServer);
instant_remote_process([
- "mkdir -p $targetCloneDir",
- "chmod 777 $targetCloneDir",
+ "mkdir -p {$tgtDir}",
+ "chmod 777 {$tgtDir}",
], $this->targetServer);
instant_scp(
@@ -74,8 +81,8 @@ protected function cloneRemoteVolume()
);
instant_remote_process([
- "docker volume create $this->targetVolume",
- "docker run --rm -v $this->targetVolume:/target -v $targetCloneDir:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
+ "docker volume create {$tgtVol}",
+ "docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
], $this->targetServer);
} catch (\Exception $e) {
@@ -84,7 +91,7 @@ protected function cloneRemoteVolume()
} finally {
try {
instant_remote_process([
- "rm -rf $sourceCloneDir",
+ "rm -rf {$srcDir}",
], $this->sourceServer, false);
} catch (\Exception $e) {
\Log::warning('Failed to clean up source server clone directory: '.$e->getMessage());
@@ -93,7 +100,7 @@ protected function cloneRemoteVolume()
try {
if ($this->targetServer) {
instant_remote_process([
- "rm -rf $targetCloneDir",
+ "rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
diff --git a/app/Livewire/Admin/Index.php b/app/Livewire/Admin/Index.php
index d1345e7bf..4d22047cc 100644
--- a/app/Livewire/Admin/Index.php
+++ b/app/Livewire/Admin/Index.php
@@ -37,7 +37,7 @@ public function back()
Auth::login($user);
refreshSession($team_to_switch_to);
- return redirect(request()->header('Referer'));
+ return redirect()->route('admin.index');
}
}
@@ -70,7 +70,7 @@ public function switchUser(int $user_id)
Auth::login($user);
refreshSession($team_to_switch_to);
- return redirect(request()->header('Referer'));
+ return redirect()->route('dashboard');
}
private function authorizeAdminAccess(): void
diff --git a/app/Livewire/Destination/Show.php b/app/Livewire/Destination/Show.php
index f2cdad074..9d55d7462 100644
--- a/app/Livewire/Destination/Show.php
+++ b/app/Livewire/Destination/Show.php
@@ -2,9 +2,7 @@
namespace App\Livewire\Destination;
-use App\Models\Server;
use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
@@ -29,16 +27,8 @@ class Show extends Component
public function mount(string $destination_uuid)
{
try {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first() ??
- SwarmDocker::whereUuid($destination_uuid)->firstOrFail();
-
- $ownedByTeam = Server::ownedByCurrentTeam()->each(function ($server) use ($destination) {
- if ($server->standaloneDockers->contains($destination) || $server->swarmDockers->contains($destination)) {
- $this->destination = $destination;
- $this->syncData();
- }
- });
- if ($ownedByTeam === false) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
return redirect()->route('destination.index');
}
$this->destination = $destination;
@@ -80,7 +70,7 @@ public function delete()
try {
$this->authorize('delete', $this->destination);
- if ($this->destination->getMorphClass() === \App\Models\StandaloneDocker::class) {
+ if ($this->destination->getMorphClass() === StandaloneDocker::class) {
if ($this->destination->attachedTo()) {
return $this->dispatch('error', 'You must delete all resources before deleting this destination.');
}
diff --git a/app/Livewire/Help.php b/app/Livewire/Help.php
index 490515875..421e50bcc 100644
--- a/app/Livewire/Help.php
+++ b/app/Livewire/Help.php
@@ -15,7 +15,7 @@ class Help extends Component
#[Validate(['required', 'min:10', 'max:1000'])]
public string $description;
- #[Validate(['required', 'min:3'])]
+ #[Validate(['required', 'min:3', 'max:600'])]
public string $subject;
public function submit()
diff --git a/app/Livewire/Project/Application/General.php b/app/Livewire/Project/Application/General.php
index 2a0047e21..258b54eed 100644
--- a/app/Livewire/Project/Application/General.php
+++ b/app/Livewire/Project/Application/General.php
@@ -197,12 +197,12 @@ protected function messages(): array
'baseDirectory.regex' => 'The base directory must be a valid path starting with / and containing only safe characters.',
'publishDirectory.regex' => 'The publish directory must be a valid path starting with / and containing only safe characters.',
'dockerfileTargetBuild.regex' => 'The Dockerfile target build must contain only alphanumeric characters, dots, hyphens, and underscores.',
- 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'installCommand.regex' => 'The install command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'buildCommand.regex' => 'The build command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
- 'startCommand.regex' => 'The start command contains invalid characters. Shell operators like ;, |, $, and backticks are not allowed.',
+ 'dockerComposeCustomStartCommand.regex' => 'The Docker Compose start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'dockerComposeCustomBuildCommand.regex' => 'The Docker Compose build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'customDockerRunOptions.regex' => 'The custom Docker run options contain invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'installCommand.regex' => 'The install command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'buildCommand.regex' => 'The build command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
+ 'startCommand.regex' => 'The start command contains invalid characters. Allowed: alphanumerics, && / || chaining, balanced quotes, globs (*, ?), !, and safe path/arg chars. Blocked: bare &, bare |, ;, $, backtick, (, ), <, >, \\, newlines.',
'preDeploymentCommandContainer.regex' => 'The pre-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'postDeploymentCommandContainer.regex' => 'The post-deployment command container name must contain only alphanumeric characters, dots, hyphens, and underscores.',
'name.required' => 'The Name field is required.',
diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php
index e06629d10..2583c10ea 100644
--- a/app/Livewire/Project/Database/Clickhouse/General.php
+++ b/app/Livewire/Project/Database/Clickhouse/General.php
@@ -76,8 +76,12 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'clickhouseAdminUser' => 'required|string',
- 'clickhouseAdminPassword' => 'required|string',
+ 'clickhouseAdminUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->clickhouseAdminUser !== $this->database->clickhouse_admin_user,
+ ),
+ 'clickhouseAdminPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->clickhouseAdminPassword !== $this->database->clickhouse_admin_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -96,10 +100,8 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
- 'clickhouseAdminUser.required' => 'The Admin User field is required.',
- 'clickhouseAdminUser.string' => 'The Admin User must be a string.',
- 'clickhouseAdminPassword.required' => 'The Admin Password field is required.',
- 'clickhouseAdminPassword.string' => 'The Admin Password must be a string.',
+ ...ValidationPatterns::databaseIdentifierMessages('clickhouseAdminUser', 'Admin User'),
+ ...ValidationPatterns::databasePasswordMessages('clickhouseAdminPassword', 'Admin Password'),
'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.',
diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php
index 5176f5ff9..9e1ea0d10 100644
--- a/app/Livewire/Project/Database/Dragonfly/General.php
+++ b/app/Livewire/Project/Database/Dragonfly/General.php
@@ -89,7 +89,9 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'dragonflyPassword' => 'required|string',
+ 'dragonflyPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->dragonflyPassword !== $this->database->dragonfly_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -109,8 +111,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
- 'dragonflyPassword.required' => 'The Dragonfly Password field is required.',
- 'dragonflyPassword.string' => 'The Dragonfly Password must be a string.',
+ ...ValidationPatterns::databasePasswordMessages('dragonflyPassword', 'Dragonfly Password'),
'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.',
diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php
index b50f196a8..7c8808499 100644
--- a/app/Livewire/Project/Database/Keydb/General.php
+++ b/app/Livewire/Project/Database/Keydb/General.php
@@ -92,7 +92,9 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'keydbConf' => 'nullable|string',
- 'keydbPassword' => 'required|string',
+ 'keydbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->keydbPassword !== $this->database->keydb_password,
+ ),
'image' => 'required|string',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -114,8 +116,7 @@ protected function messages(): array
ValidationPatterns::combinedMessages(),
ValidationPatterns::portMappingMessages(),
[
- 'keydbPassword.required' => 'The KeyDB Password field is required.',
- 'keydbPassword.string' => 'The KeyDB Password must be a string.',
+ ...ValidationPatterns::databasePasswordMessages('keydbPassword', 'KeyDB Password'),
'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.',
diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php
index 9a1a8bd68..ea6d902e7 100644
--- a/app/Livewire/Project/Database/Mariadb/General.php
+++ b/app/Livewire/Project/Database/Mariadb/General.php
@@ -74,10 +74,18 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mariadbRootPassword' => 'required',
- 'mariadbUser' => 'required',
- 'mariadbPassword' => 'required',
- 'mariadbDatabase' => 'required',
+ 'mariadbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbRootPassword !== $this->database->mariadb_root_password,
+ ),
+ 'mariadbUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbUser !== $this->database->mariadb_user,
+ ),
+ 'mariadbPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mariadbPassword !== $this->database->mariadb_password,
+ ),
+ 'mariadbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mariadbDatabase !== $this->database->mariadb_database,
+ ),
'mariadbConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@@ -97,10 +105,10 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'mariadbRootPassword.required' => 'The Root Password field is required.',
- 'mariadbUser.required' => 'The MariaDB User field is required.',
- 'mariadbPassword.required' => 'The MariaDB Password field is required.',
- 'mariadbDatabase.required' => 'The MariaDB Database field is required.',
+ ...ValidationPatterns::databasePasswordMessages('mariadbRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mariadbUser', 'MariaDB User'),
+ ...ValidationPatterns::databasePasswordMessages('mariadbPassword', 'MariaDB Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mariadbDatabase', 'MariaDB Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php
index a21de744a..3af4b0b2a 100644
--- a/app/Livewire/Project/Database/Mongodb/General.php
+++ b/app/Livewire/Project/Database/Mongodb/General.php
@@ -75,9 +75,15 @@ protected function rules(): array
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'mongoConf' => 'nullable',
- 'mongoInitdbRootUsername' => 'required',
- 'mongoInitdbRootPassword' => 'required',
- 'mongoInitdbDatabase' => 'required',
+ 'mongoInitdbRootUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbRootUsername !== $this->database->mongo_initdb_root_username,
+ ),
+ 'mongoInitdbRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mongoInitdbRootPassword !== $this->database->mongo_initdb_root_password,
+ ),
+ 'mongoInitdbDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mongoInitdbDatabase !== $this->database->mongo_initdb_database,
+ ),
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
'isPublic' => 'nullable|boolean',
@@ -97,9 +103,9 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'mongoInitdbRootUsername.required' => 'The Root Username field is required.',
- 'mongoInitdbRootPassword.required' => 'The Root Password field is required.',
- 'mongoInitdbDatabase.required' => 'The MongoDB Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbRootUsername', 'Root Username'),
+ ...ValidationPatterns::databasePasswordMessages('mongoInitdbRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mongoInitdbDatabase', 'MongoDB Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php
index cacb4ac49..34726bd0a 100644
--- a/app/Livewire/Project/Database/Mysql/General.php
+++ b/app/Livewire/Project/Database/Mysql/General.php
@@ -76,10 +76,18 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'mysqlRootPassword' => 'required',
- 'mysqlUser' => 'required',
- 'mysqlPassword' => 'required',
- 'mysqlDatabase' => 'required',
+ 'mysqlRootPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlRootPassword !== $this->database->mysql_root_password,
+ ),
+ 'mysqlUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlUser !== $this->database->mysql_user,
+ ),
+ 'mysqlPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->mysqlPassword !== $this->database->mysql_password,
+ ),
+ 'mysqlDatabase' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->mysqlDatabase !== $this->database->mysql_database,
+ ),
'mysqlConf' => 'nullable',
'image' => 'required',
'portsMappings' => ValidationPatterns::portMappingRules(),
@@ -100,10 +108,10 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'mysqlRootPassword.required' => 'The Root Password field is required.',
- 'mysqlUser.required' => 'The MySQL User field is required.',
- 'mysqlPassword.required' => 'The MySQL Password field is required.',
- 'mysqlDatabase.required' => 'The MySQL Database field is required.',
+ ...ValidationPatterns::databasePasswordMessages('mysqlRootPassword', 'Root Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlUser', 'MySQL User'),
+ ...ValidationPatterns::databasePasswordMessages('mysqlPassword', 'MySQL Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('mysqlDatabase', 'MySQL Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
diff --git a/app/Livewire/Project/Database/Postgresql/General.php b/app/Livewire/Project/Database/Postgresql/General.php
index 22e350683..b5fb85483 100644
--- a/app/Livewire/Project/Database/Postgresql/General.php
+++ b/app/Livewire/Project/Database/Postgresql/General.php
@@ -86,9 +86,15 @@ protected function rules(): array
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
- 'postgresUser' => 'required',
- 'postgresPassword' => 'required',
- 'postgresDb' => 'required',
+ 'postgresUser' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresUser !== $this->database->postgres_user,
+ ),
+ 'postgresPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->postgresPassword !== $this->database->postgres_password,
+ ),
+ 'postgresDb' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->postgresDb !== $this->database->postgres_db,
+ ),
'postgresInitdbArgs' => 'nullable',
'postgresHostAuthMethod' => 'nullable',
'postgresConf' => 'nullable',
@@ -112,9 +118,9 @@ protected function messages(): array
ValidationPatterns::portMappingMessages(),
[
'name.required' => 'The Name field is required.',
- 'postgresUser.required' => 'The Postgres User field is required.',
- 'postgresPassword.required' => 'The Postgres Password field is required.',
- 'postgresDb.required' => 'The Postgres Database field is required.',
+ ...ValidationPatterns::databaseIdentifierMessages('postgresUser', 'Postgres User'),
+ ...ValidationPatterns::databasePasswordMessages('postgresPassword', 'Postgres Password'),
+ ...ValidationPatterns::databaseIdentifierMessages('postgresDb', 'Postgres Database'),
'image.required' => 'The Docker Image field is required.',
'publicPort.integer' => 'The Public Port must be an integer.',
'publicPort.min' => 'The Public Port must be at least 1.',
@@ -352,9 +358,14 @@ public function save_init_script($script)
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($oldScript['filename'], 'init script filename');
- $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
+ // New filename is user-supplied — must be safe before accepting the rename.
+ validateFilenameSafe($script['filename'], 'init script filename');
+
+ // Old filename may be a legacy value written before this validation existed.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() contains
+ // any remaining shell-metachars. No validator — don't block cleanup of legacy rows.
+ $old_filename = basename($oldScript['filename']);
+ $old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$old_filename}";
$escapedOldPath = escapeshellarg($old_file_path);
$delete_command = "rm -f {$escapedOldPath}";
instant_remote_process([$delete_command], $this->server);
@@ -398,9 +409,11 @@ public function delete_init_script($script)
$configuration_dir = database_configuration_dir().'/'.$container_name;
try {
- // Validate and escape filename to prevent command injection
- validateShellSafePath($script['filename'], 'init script filename');
- $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
+ // Allow deletion of legacy rows with unsafe filenames so operators can clean up.
+ // basename() scopes the rm to the initdb.d directory; escapeshellarg() keeps the
+ // shell invocation safe regardless of the stored value.
+ $safe_filename = basename($script['filename']);
+ $file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$safe_filename}";
$escapedPath = escapeshellarg($file_path);
$command = "rm -f {$escapedPath}";
@@ -437,8 +450,8 @@ public function save_new_init_script()
]);
try {
- // Validate filename to prevent command injection
- validateShellSafePath($this->new_filename, 'init script filename');
+ // Validate filename to prevent path traversal and command injection
+ validateFilenameSafe($this->new_filename, 'init script filename');
} catch (Exception $e) {
$this->dispatch('error', $e->getMessage());
diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php
index 3c32a6192..c3cc43972 100644
--- a/app/Livewire/Project/Database/Redis/General.php
+++ b/app/Livewire/Project/Database/Redis/General.php
@@ -81,8 +81,12 @@ protected function rules(): array
'publicPortTimeout' => 'nullable|integer|min:1',
'isLogDrainEnabled' => 'nullable|boolean',
'customDockerRunOptions' => 'nullable',
- 'redisUsername' => 'required',
- 'redisPassword' => 'required',
+ 'redisUsername' => ValidationPatterns::databaseIdentifierRules(
+ enforcePattern: $this->redisUsername !== $this->database->redis_username,
+ ),
+ 'redisPassword' => ValidationPatterns::databasePasswordRules(
+ enforcePattern: $this->redisPassword !== $this->database->redis_password,
+ ),
'enableSsl' => 'boolean',
];
}
@@ -100,8 +104,8 @@ protected function messages(): array
'publicPort.max' => 'The Public Port must not exceed 65535.',
'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.',
+ ...ValidationPatterns::databaseIdentifierMessages('redisUsername', 'Redis Username'),
+ ...ValidationPatterns::databasePasswordMessages('redisPassword', 'Redis Password'),
]
);
}
diff --git a/app/Livewire/Project/New/DockerCompose.php b/app/Livewire/Project/New/DockerCompose.php
index 2b92902c6..2cf0659bf 100644
--- a/app/Livewire/Project/New/DockerCompose.php
+++ b/app/Livewire/Project/New/DockerCompose.php
@@ -5,8 +5,6 @@
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
@@ -31,7 +29,6 @@ public function mount()
public function submit()
{
- $server_id = $this->query['server_id'];
try {
$this->validate([
'dockerComposeRaw' => 'required',
@@ -44,20 +41,17 @@ public function submit()
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
$service = Service::create([
'docker_compose_raw' => $this->dockerComposeRaw,
'environment_id' => $environment->id,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination_class,
]);
diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php
index 268333d07..b89ce2c6a 100644
--- a/app/Livewire/Project/New/DockerImage.php
+++ b/app/Livewire/Project/New/DockerImage.php
@@ -4,8 +4,6 @@
use App\Models\Application;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Services\DockerImageParser;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -111,13 +109,10 @@ public function submit()
$parser = new DockerImageParser;
$parser->parse($dockerImage);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php
index be7daddd7..0ce1bd1a2 100644
--- a/app/Livewire/Project/New/GithubPrivateRepository.php
+++ b/app/Livewire/Project/New/GithubPrivateRepository.php
@@ -5,8 +5,6 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
@@ -180,13 +178,10 @@ public function submit()
throw new \RuntimeException('Invalid repository data: '.$validator->errors()->first());
}
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
index e81139792..045ddc6cb 100644
--- a/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
+++ b/app/Livewire/Project/New/GithubPrivateRepositoryDeployKey.php
@@ -7,8 +7,6 @@
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -132,13 +130,10 @@ public function submit()
{
$this->validate();
try {
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/New/PublicGitRepository.php b/app/Livewire/Project/New/PublicGitRepository.php
index fb24ba284..9fe630d63 100644
--- a/app/Livewire/Project/New/PublicGitRepository.php
+++ b/app/Livewire/Project/New/PublicGitRepository.php
@@ -7,8 +7,6 @@
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -34,8 +32,6 @@ class PublicGitRepository extends Component
public bool $isStatic = false;
- public bool $checkCoolifyConfig = true;
-
public ?string $publish_directory = null;
// In case of docker compose
@@ -286,16 +282,13 @@ public function submit()
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
- $destination_uuid = $this->query['destination'];
+ $destination_uuid = $this->query['destination'] ?? null;
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
@@ -373,12 +366,6 @@ public function submit()
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
$application->fqdn = $fqdn;
$application->save();
- if ($this->checkCoolifyConfig) {
- // $config = loadConfigFromGit($this->repository_url, $this->git_branch, $this->base_directory, $this->query['server_id'], auth()->user()->currentTeam()->id);
- // if ($config) {
- // $application->setConfig($config);
- // }
- }
return redirect()->route('project.application.configuration', [
'application_uuid' => $application->uuid,
diff --git a/app/Livewire/Project/New/SimpleDockerfile.php b/app/Livewire/Project/New/SimpleDockerfile.php
index 1073157e6..f07948dba 100644
--- a/app/Livewire/Project/New/SimpleDockerfile.php
+++ b/app/Livewire/Project/New/SimpleDockerfile.php
@@ -5,8 +5,6 @@
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
-use App\Models\StandaloneDocker;
-use App\Models\SwarmDocker;
use Livewire\Component;
use Visus\Cuid2\Cuid2;
@@ -35,13 +33,10 @@ public function submit()
$this->validate([
'dockerfile' => 'required',
]);
- $destination_uuid = $this->query['destination'];
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
+ $destination_uuid = $this->query['destination'] ?? null;
+ $destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
- $destination = SwarmDocker::where('uuid', $destination_uuid)->first();
- }
- if (! $destination) {
- throw new \Exception('Destination not found. What?!');
+ throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
diff --git a/app/Livewire/Project/Resource/Create.php b/app/Livewire/Project/Resource/Create.php
index 966c66a14..4619ddf37 100644
--- a/app/Livewire/Project/Resource/Create.php
+++ b/app/Livewire/Project/Resource/Create.php
@@ -4,7 +4,6 @@
use App\Models\EnvironmentVariable;
use App\Models\Service;
-use App\Models\StandaloneDocker;
use Livewire\Component;
class Create extends Component
@@ -18,7 +17,6 @@ public function mount()
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
- $server_id = request()->query('server_id');
$database_image = request()->query('database_image');
$project = currentTeam()->load(['projects'])->projects->where('uuid', request()->route('project_uuid'))->first();
@@ -30,7 +28,11 @@ public function mount()
if (! $environment) {
return redirect()->route('dashboard');
}
- if (isset($type) && isset($destination_uuid) && isset($server_id)) {
+ if (isset($type) && isset($destination_uuid)) {
+ $destination = find_destination_for_current_team($destination_uuid);
+ if (! $destination) {
+ return redirect()->route('dashboard');
+ }
$services = get_service_templates();
if (in_array($type, DATABASE_TYPES)) {
@@ -44,23 +46,23 @@ public function mount()
}
$database = create_standalone_postgresql(
environmentId: $environment->id,
- destinationUuid: $destination_uuid,
+ destination: $destination,
databaseImage: $database_image
);
} elseif ($type->value() === 'redis') {
- $database = create_standalone_redis($environment->id, $destination_uuid);
+ $database = create_standalone_redis($environment->id, $destination);
} elseif ($type->value() === 'mongodb') {
- $database = create_standalone_mongodb($environment->id, $destination_uuid);
+ $database = create_standalone_mongodb($environment->id, $destination);
} elseif ($type->value() === 'mysql') {
- $database = create_standalone_mysql($environment->id, $destination_uuid);
+ $database = create_standalone_mysql($environment->id, $destination);
} elseif ($type->value() === 'mariadb') {
- $database = create_standalone_mariadb($environment->id, $destination_uuid);
+ $database = create_standalone_mariadb($environment->id, $destination);
} elseif ($type->value() === 'keydb') {
- $database = create_standalone_keydb($environment->id, $destination_uuid);
+ $database = create_standalone_keydb($environment->id, $destination);
} elseif ($type->value() === 'dragonfly') {
- $database = create_standalone_dragonfly($environment->id, $destination_uuid);
+ $database = create_standalone_dragonfly($environment->id, $destination);
} elseif ($type->value() === 'clickhouse') {
- $database = create_standalone_clickhouse($environment->id, $destination_uuid);
+ $database = create_standalone_clickhouse($environment->id, $destination);
}
return redirect()->route('project.database.configuration', [
@@ -69,7 +71,7 @@ public function mount()
'database_uuid' => $database->uuid,
]);
}
- if ($type->startsWith('one-click-service-') && ! is_null((int) $server_id)) {
+ if ($type->startsWith('one-click-service-')) {
$oneClickServiceName = $type->after('one-click-service-')->value();
$oneClickService = data_get($services, "$oneClickServiceName.compose");
$oneClickDotEnvs = data_get($services, "$oneClickServiceName.envs", null);
@@ -79,12 +81,11 @@ public function mount()
});
}
if ($oneClickService) {
- $destination = StandaloneDocker::whereUuid($destination_uuid)->first();
$service_payload = [
'docker_compose_raw' => base64_decode($oneClickService),
'environment_id' => $environment->id,
'service_type' => $oneClickServiceName,
- 'server_id' => (int) $server_id,
+ 'server_id' => $destination->server_id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 433c2b13c..6f43662d5 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -106,8 +106,12 @@ public function submitPersistentVolume()
$this->validate([
'name' => ValidationPatterns::volumeNameRules(),
'mount_path' => 'required|string',
- 'host_path' => $this->isSwarm ? 'required|string' : 'string|nullable',
- ], ValidationPatterns::volumeNameMessages());
+ 'host_path' => $this->isSwarm
+ ? ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN]
+ : ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ ], array_merge(ValidationPatterns::volumeNameMessages(), [
+ 'host_path.regex' => 'Host path must start with / and only contain safe path characters.',
+ ]));
$name = $this->resource->uuid.'-'.$this->name;
diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php
index f4813dd4c..2a8747c33 100644
--- a/app/Livewire/Project/Shared/ResourceOperations.php
+++ b/app/Livewire/Project/Shared/ResourceOperations.php
@@ -58,10 +58,9 @@ public function cloneTo($destination_id)
{
$this->authorize('update', $this->resource);
- $teamScope = fn ($q) => $q->where('team_id', currentTeam()->id);
- $new_destination = StandaloneDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = StandaloneDocker::ownedByCurrentTeam()->find($destination_id);
if (! $new_destination) {
- $new_destination = SwarmDocker::whereHas('server', $teamScope)->find($destination_id);
+ $new_destination = SwarmDocker::ownedByCurrentTeam()->find($destination_id);
}
if (! $new_destination) {
return $this->addError('destination_id', 'Destination not found.');
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index eee5a0776..2aaca5e6f 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Shared\Storages;
use App\Models\LocalPersistentVolume;
+use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -31,19 +32,33 @@ class Show extends Component
public bool $isPreviewSuffixEnabled = true;
- protected $rules = [
- 'name' => 'required|string',
- 'mountPath' => 'required|string',
- 'hostPath' => 'string|nullable',
- 'isPreviewSuffixEnabled' => 'required|boolean',
- ];
-
protected $validationAttributes = [
'name' => 'name',
'mountPath' => 'mount',
'hostPath' => 'host',
];
+ protected function rules(): array
+ {
+ return [
+ 'name' => ValidationPatterns::volumeNameRules(),
+ 'mountPath' => ['required', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'hostPath' => ['nullable', 'string', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
+ 'isPreviewSuffixEnabled' => 'required|boolean',
+ ];
+ }
+
+ protected function messages(): array
+ {
+ return array_merge(
+ ValidationPatterns::volumeNameMessages(),
+ [
+ 'mountPath.regex' => 'Mount path must start with / and only contain safe path characters.',
+ 'hostPath.regex' => 'Host path must start with / and only contain safe path characters.',
+ ]
+ );
+ }
+
/**
* Sync data between component properties and model
*
diff --git a/app/Livewire/Security/ApiTokens.php b/app/Livewire/Security/ApiTokens.php
index a263acedf..37d5332f3 100644
--- a/app/Livewire/Security/ApiTokens.php
+++ b/app/Livewire/Security/ApiTokens.php
@@ -13,10 +13,20 @@ class ApiTokens extends Component
public ?string $description = null;
+ public ?int $expiresInDays = 30;
+
public $tokens = [];
public array $permissions = ['read'];
+ public array $expirationOptions = [
+ 7 => '7 days',
+ 30 => '30 days',
+ 60 => '60 days',
+ 90 => '90 days',
+ 365 => '1 year',
+ ];
+
public $isApiEnabled;
public bool $canUseRootPermissions = false;
@@ -90,8 +100,10 @@ public function addNewToken()
$this->validate([
'description' => 'required|min:3|max:255',
+ 'expiresInDays' => 'nullable|integer|in:7,30,60,90,365',
]);
- $token = auth()->user()->createToken($this->description, array_values($this->permissions));
+ $expiresAt = $this->expiresInDays ? now()->addDays($this->expiresInDays) : null;
+ $token = auth()->user()->createToken($this->description, array_values($this->permissions), $expiresAt);
$this->getTokens();
session()->flash('token', $token->plainTextToken);
} catch (\Exception $e) {
diff --git a/app/Livewire/Settings/Index.php b/app/Livewire/Settings/Index.php
index 9a51d107d..c2789aa91 100644
--- a/app/Livewire/Settings/Index.php
+++ b/app/Livewire/Settings/Index.php
@@ -35,7 +35,7 @@ class Index extends Component
#[Validate('required|string|timezone')]
public string $instance_timezone;
- #[Validate('nullable|string|max:50')]
+ #[Validate(['nullable', 'string', 'max:128', 'regex:/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/'])]
public ?string $dev_helper_version = null;
public array $domainConflicts = [];
@@ -49,6 +49,7 @@ class Index extends Component
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
+ 'dev_helper_version.regex' => 'Dev helper version must match Docker tag format (alphanumeric, _, ., -; first char cannot be . or -).',
];
public function render()
@@ -184,6 +185,8 @@ public function buildHelperImage()
return;
}
+ $this->validateOnly('dev_helper_version');
+
$version = $this->dev_helper_version ?: config('constants.coolify.helper_version');
if (empty($version)) {
$this->dispatch('error', 'Please specify a version to build.');
@@ -191,7 +194,14 @@ public function buildHelperImage()
return;
}
- $buildCommand = "docker build -t ghcr.io/coollabsio/coolify-helper:{$version} -f docker/coolify-helper/Dockerfile .";
+ if (! preg_match('/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/', (string) $version)) {
+ $this->dispatch('error', 'Invalid helper version format.');
+
+ return;
+ }
+
+ $imageRef = escapeshellarg("ghcr.io/coollabsio/coolify-helper:{$version}");
+ $buildCommand = "docker build -t {$imageRef} -f docker/coolify-helper/Dockerfile .";
$activity = remote_process(
command: [$buildCommand],
diff --git a/app/Livewire/Storage/Create.php b/app/Livewire/Storage/Create.php
index eda20342b..c3db34066 100644
--- a/app/Livewire/Storage/Create.php
+++ b/app/Livewire/Storage/Create.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@@ -37,7 +38,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
- 'endpoint' => 'required|url|max:255',
+ 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -55,7 +56,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
- 'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
diff --git a/app/Livewire/Storage/Form.php b/app/Livewire/Storage/Form.php
index 791226334..342d629cb 100644
--- a/app/Livewire/Storage/Form.php
+++ b/app/Livewire/Storage/Form.php
@@ -3,6 +3,7 @@
namespace App\Livewire\Storage;
use App\Models\S3Storage;
+use App\Rules\SafeWebhookUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -42,7 +43,7 @@ protected function rules(): array
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
- 'endpoint' => 'required|url|max:255',
+ 'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -60,7 +61,6 @@ protected function messages(): array
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
- 'endpoint.url' => 'The Endpoint must be a valid URL.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
);
diff --git a/app/Livewire/Storage/Resources.php b/app/Livewire/Storage/Resources.php
index 643ecb3eb..0dad2d548 100644
--- a/app/Livewire/Storage/Resources.php
+++ b/app/Livewire/Storage/Resources.php
@@ -25,7 +25,9 @@ public function mount(): void
public function disableS3(int $backupId): void
{
- $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+ $backup = ScheduledDatabaseBackup::where('id', $backupId)
+ ->where('s3_storage_id', $this->storage->id)
+ ->firstOrFail();
$backup->update([
'save_s3' => false,
@@ -39,7 +41,9 @@ public function disableS3(int $backupId): void
public function moveBackup(int $backupId): void
{
- $backup = ScheduledDatabaseBackup::findOrFail($backupId);
+ $backup = ScheduledDatabaseBackup::where('id', $backupId)
+ ->where('s3_storage_id', $this->storage->id)
+ ->firstOrFail();
$newStorageId = $this->selectedStorages[$backupId] ?? null;
if (! $newStorageId || (int) $newStorageId === $this->storage->id) {
diff --git a/app/Models/Application.php b/app/Models/Application.php
index f5d10dece..2201b1d16 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -215,14 +215,27 @@ class Application extends BaseModel
protected $appends = ['server_status'];
- protected $casts = [
- 'http_basic_auth_password' => 'encrypted',
- 'restart_count' => 'integer',
- 'last_restart_at' => 'datetime',
- ];
+ protected function casts(): array
+ {
+ return [
+ 'http_basic_auth_password' => 'encrypted',
+ 'manual_webhook_secret_github' => 'encrypted',
+ 'manual_webhook_secret_gitlab' => 'encrypted',
+ 'manual_webhook_secret_bitbucket' => 'encrypted',
+ 'manual_webhook_secret_gitea' => 'encrypted',
+ 'restart_count' => 'integer',
+ 'last_restart_at' => 'datetime',
+ ];
+ }
protected static function booted()
{
+ static::creating(function ($application) {
+ $application->manual_webhook_secret_github ??= Str::random(40);
+ $application->manual_webhook_secret_gitlab ??= Str::random(40);
+ $application->manual_webhook_secret_bitbucket ??= Str::random(40);
+ $application->manual_webhook_secret_gitea ??= Str::random(40);
+ });
static::addGlobalScope('withRelations', function ($builder) {
$builder->withCount([
'additional_servers',
diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php
index f08a48cea..9159fd0d8 100644
--- a/app/Models/ApplicationPreview.php
+++ b/app/Models/ApplicationPreview.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
@@ -42,11 +43,18 @@ protected static function booted()
$networkKeys = collect($networks)->keys();
$volumeKeys = collect($volumes)->keys();
$volumeKeys->each(function ($key) use ($server) {
- instant_remote_process(["docker volume rm -f $key"], $server, false);
+ if (! preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key)) {
+ return;
+ }
+ instant_remote_process(['docker volume rm -f '.escapeshellarg($key)], $server, false);
});
$networkKeys->each(function ($key) use ($server) {
- instant_remote_process(["docker network disconnect $key coolify-proxy"], $server, false);
- instant_remote_process(["docker network rm $key"], $server, false);
+ if (! preg_match(ValidationPatterns::DOCKER_NETWORK_PATTERN, $key)) {
+ return;
+ }
+ $k = escapeshellarg($key);
+ instant_remote_process(["docker network disconnect {$k} coolify-proxy"], $server, false);
+ instant_remote_process(["docker network rm {$k}"], $server, false);
});
} else {
// Regular application volume cleanup
diff --git a/app/Models/S3Storage.php b/app/Models/S3Storage.php
index d6feccc7e..3f6ee51cc 100644
--- a/app/Models/S3Storage.php
+++ b/app/Models/S3Storage.php
@@ -2,11 +2,13 @@
namespace App\Models;
+use App\Rules\SafeWebhookUrl;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Facades\Validator;
class S3Storage extends BaseModel
{
@@ -66,6 +68,13 @@ public static function ownedByCurrentTeam(array $select = ['*'])
return S3Storage::whereTeamId(currentTeam()->id)->select($selectArray->all())->orderBy('name');
}
+ public static function ownedByCurrentTeamAPI(int $teamId, array $select = ['*'])
+ {
+ $selectArray = collect($select)->concat(['id']);
+
+ return S3Storage::whereTeamId($teamId)->select($selectArray->all())->orderBy('name');
+ }
+
public function isUsable()
{
return $this->is_usable;
@@ -132,6 +141,14 @@ protected function region(): Attribute
public function testConnection(bool $shouldSave = false)
{
try {
+ $validator = Validator::make(
+ ['endpoint' => $this['endpoint']],
+ ['endpoint' => ['required', new SafeWebhookUrl]],
+ );
+ if ($validator->fails()) {
+ throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
+ }
+
$disk = Storage::build([
'driver' => 's3',
'region' => $this['region'],
diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php
index dcb349405..d6b4d1a1c 100644
--- a/app/Models/StandaloneDocker.php
+++ b/app/Models/StandaloneDocker.php
@@ -90,6 +90,16 @@ public function server()
return $this->belongsTo(Server::class);
}
+ public static function ownedByCurrentTeam()
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
+ }
+
+ public static function ownedByCurrentTeamAPI(int $teamId)
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
+ }
+
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
diff --git a/app/Models/SwarmDocker.php b/app/Models/SwarmDocker.php
index 134e36189..0e9620457 100644
--- a/app/Models/SwarmDocker.php
+++ b/app/Models/SwarmDocker.php
@@ -71,6 +71,16 @@ public function server()
return $this->belongsTo(Server::class);
}
+ public static function ownedByCurrentTeam()
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId(currentTeam()->id));
+ }
+
+ public static function ownedByCurrentTeamAPI(int $teamId)
+ {
+ return static::whereHas('server', fn ($q) => $q->whereTeamId($teamId));
+ }
+
/**
* Get the server attribute using identity map caching.
* This intercepts lazy-loading to use cached Server lookups.
diff --git a/app/Models/User.php b/app/Models/User.php
index 3199d2024..237f3836f 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -257,7 +257,7 @@ public function sendVerificationEmail()
Carbon::now()->addMinutes(Config::get('auth.verification.expire', 60)),
[
'id' => $this->getKey(),
- 'hash' => sha1($this->getEmailForVerification()),
+ 'hash' => hash('sha256', $this->getEmailForVerification()),
]
);
$mail->view('emails.email-verification', [
diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php
new file mode 100644
index 000000000..451dd312a
--- /dev/null
+++ b/app/Notifications/ApiTokenExpiringNotification.php
@@ -0,0 +1,103 @@
+onQueue('high');
+ $this->tokenName = $token->name;
+ $this->expiresAt = $token->expires_at?->format('Y-m-d H:i:s') ?? '';
+ $this->manageUrl = route('security.api-tokens');
+ }
+
+ public function via(object $notifiable): array
+ {
+ return $notifiable->getEnabledChannels('api_token_expiring');
+ }
+
+ public function toMail(): MailMessage
+ {
+ $mail = new MailMessage;
+ $mail->subject("Coolify: API token '{$this->tokenName}' expires in 24 hours");
+ $mail->view('emails.api-token-expiring', [
+ 'tokenName' => $this->tokenName,
+ 'expiresAt' => $this->expiresAt,
+ 'manageUrl' => $this->manageUrl,
+ ]);
+
+ return $mail;
+ }
+
+ public function toDiscord(): DiscordMessage
+ {
+ $message = new DiscordMessage(
+ title: '🔑 API token expiring soon',
+ description: "API token **{$this->tokenName}** expires on {$this->expiresAt}.\n\n**Action Required:** Rotate this token before it expires to avoid API outages.",
+ color: DiscordMessage::warningColor(),
+ );
+
+ $message->addField('Manage tokens', "[Open Security settings]({$this->manageUrl})");
+
+ return $message;
+ }
+
+ public function toTelegram(): array
+ {
+ $message = "Coolify: API token '{$this->tokenName}' expires on {$this->expiresAt}.\n\nAction Required: Rotate this token before it expires to avoid API outages.";
+
+ return [
+ 'message' => $message,
+ 'buttons' => [
+ [
+ 'text' => 'Manage API tokens',
+ 'url' => $this->manageUrl,
+ ],
+ ],
+ ];
+ }
+
+ public function toPushover(): PushoverMessage
+ {
+ $message = "API token {$this->tokenName} expires on {$this->expiresAt}.
";
+ $message .= 'Action Required: Rotate this token before it expires to avoid API outages.';
+
+ return new PushoverMessage(
+ title: 'API token expiring soon',
+ level: 'warning',
+ message: $message,
+ buttons: [
+ [
+ 'text' => 'Manage API tokens',
+ 'url' => $this->manageUrl,
+ ],
+ ],
+ );
+ }
+
+ public function toSlack(): SlackMessage
+ {
+ $description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
+ $description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
+ $description .= "Manage tokens: {$this->manageUrl}";
+
+ return new SlackMessage(
+ title: '🔑 API token expiring soon',
+ description: $description,
+ color: SlackMessage::warningColor(),
+ );
+ }
+}
diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php
index 2150126cd..4068572c8 100644
--- a/app/Providers/RouteServiceProvider.php
+++ b/app/Providers/RouteServiceProvider.php
@@ -54,5 +54,9 @@ protected function configureRateLimiting(): void
RateLimiter::for('5', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
+
+ RateLimiter::for('feedback', function (Request $request) {
+ return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
+ });
}
}
diff --git a/app/Rules/SafeWebhookUrl.php b/app/Rules/SafeWebhookUrl.php
index fbeb406af..3723e1db5 100644
--- a/app/Rules/SafeWebhookUrl.php
+++ b/app/Rules/SafeWebhookUrl.php
@@ -40,9 +40,15 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$host = strtolower($host);
+ // Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
+ // literals can't sneak past filter_var FILTER_VALIDATE_IP.
+ $hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
+ ? substr($host, 1, -1)
+ : $host;
+
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
- if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) {
+ if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
@@ -55,7 +61,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
- if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
+ if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
diff --git a/app/Support/ValidationPatterns.php b/app/Support/ValidationPatterns.php
index 88121384f..58dbbe1ac 100644
--- a/app/Support/ValidationPatterns.php
+++ b/app/Support/ValidationPatterns.php
@@ -36,15 +36,31 @@ class ValidationPatterns
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
- * Pattern for shell-safe command strings (docker compose commands, docker run options)
- * Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns
- * Allows & for command chaining (&&) which is common in multi-step build commands
- * Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
- * Blocks backslashes to prevent escape-sequence attacks
- * Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'")
- * Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
+ * Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
+ *
+ * Accepts a sequence of the following tokens only:
+ * [ \t]+ — whitespace (space / tab)
+ * && — logical AND (matched before bare & can match anything)
+ * || — logical OR (matched before bare | can match anything)
+ * "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside
+ * '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal)
+ * [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !)
+ *
+ * Blocked everywhere (outside and inside unquoted tokens):
+ * bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR
+ *
+ * Blocked inside double-quoted spans specifically:
+ * $ (variable/command expansion), ` (command substitution), \ (escape)
+ *
+ * Legitimate use cases preserved:
+ * docker compose build && docker tag x && docker push y
+ * make build || make clean
+ * rm *.tmp cp src/?.js dist/
+ * ! grep -q foo && echo missing
+ * docker compose up -d --build-arg VERSION="1.0.0"
+ * --entrypoint "sh -c 'npm start'"
*/
- public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/';
+ public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/';
/**
* Pattern for Docker volume names
@@ -66,6 +82,112 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
+ /**
+ * Pattern for SQL-safe unquoted database identifiers (usernames, database names).
+ * Allows letters, digits, underscore; first char must be letter or underscore.
+ * Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit).
+ */
+ public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/';
+
+ /**
+ * Pattern for database passwords.
+ * Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null.
+ * Allows a broad set of printable characters so passwords remain strong.
+ */
+ public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
+
+ /**
+ * Get validation rules for database identifier fields (username, database name).
+ *
+ * Set $enforcePattern to false to skip the regex check (for example when
+ * re-validating a legacy value on an existing record that has not been
+ * changed by the user). The length and type rules are always applied.
+ */
+ public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "min:$minLength";
+ $rules[] = "max:$maxLength";
+
+ if ($enforcePattern) {
+ $rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for database identifier fields.
+ */
+ public static function databaseIdentifierMessages(string $field, string $label = ''): array
+ {
+ $label = $label ?: $field;
+
+ return [
+ "{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.",
+ "{$field}.min" => "The {$label} must be at least :min character.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Get validation rules for database password fields.
+ *
+ * Set $enforcePattern to false to skip the regex check (for example when
+ * re-validating a legacy value on an existing record that has not been
+ * changed by the user). The length and type rules are always applied.
+ */
+ public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array
+ {
+ $rules = [];
+
+ if ($required) {
+ $rules[] = 'required';
+ } else {
+ $rules[] = 'nullable';
+ }
+
+ $rules[] = 'string';
+ $rules[] = "min:$minLength";
+ $rules[] = "max:$maxLength";
+
+ if ($enforcePattern) {
+ $rules[] = 'regex:'.self::DB_PASSWORD_PATTERN;
+ }
+
+ return $rules;
+ }
+
+ /**
+ * Get validation messages for database password fields.
+ */
+ public static function databasePasswordMessages(string $field, string $label = ''): array
+ {
+ $label = $label ?: $field;
+
+ return [
+ "{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).",
+ "{$field}.min" => "The {$label} must be at least :min character.",
+ "{$field}.max" => "The {$label} may not be greater than :max characters.",
+ ];
+ }
+
+ /**
+ * Check if a string is a valid database identifier.
+ */
+ public static function isValidDatabaseIdentifier(string $value): bool
+ {
+ return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1;
+ }
+
/**
* Get validation rules for name fields
*/
diff --git a/app/Traits/HasNotificationSettings.php b/app/Traits/HasNotificationSettings.php
index fded435fd..9333eb504 100644
--- a/app/Traits/HasNotificationSettings.php
+++ b/app/Traits/HasNotificationSettings.php
@@ -19,6 +19,7 @@ trait HasNotificationSettings
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
+ 'api_token_expiring',
];
/**
diff --git a/bootstrap/helpers/databases.php b/bootstrap/helpers/databases.php
index 5df36db33..4d5e085f3 100644
--- a/bootstrap/helpers/databases.php
+++ b/bootstrap/helpers/databases.php
@@ -3,6 +3,7 @@
use App\Models\EnvironmentVariable;
use App\Models\S3Storage;
use App\Models\Server;
+use App\Models\ServiceDatabase;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
@@ -12,18 +13,19 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
+use Illuminate\Support\Str;
use Visus\Cuid2\Cuid2;
-function create_standalone_postgresql($environmentId, $destinationUuid, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
+function create_standalone_postgresql($environmentId, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null, string $databaseImage = 'postgres:16-alpine'): StandalonePostgresql
{
- $destination = StandaloneDocker::where('uuid', $destinationUuid)->firstOrFail();
$database = new StandalonePostgresql;
$database->uuid = (new Cuid2);
$database->name = 'postgresql-database-'.$database->uuid;
$database->image = $databaseImage;
- $database->postgres_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->postgres_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environmentId;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -35,14 +37,13 @@ function create_standalone_postgresql($environmentId, $destinationUuid, ?array $
return $database;
}
-function create_standalone_redis($environment_id, $destination_uuid, ?array $otherData = null): StandaloneRedis
+function create_standalone_redis($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneRedis
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneRedis;
$database->uuid = (new Cuid2);
$database->name = 'redis-database-'.$database->uuid;
- $redis_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $redis_password = Str::password(length: 64, symbols: false);
if ($otherData && isset($otherData['redis_password'])) {
$redis_password = $otherData['redis_password'];
unset($otherData['redis_password']);
@@ -75,13 +76,12 @@ function create_standalone_redis($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_mongodb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMongodb
+function create_standalone_mongodb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMongodb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMongodb;
$database->uuid = (new Cuid2);
$database->name = 'mongodb-database-'.$database->uuid;
- $database->mongo_initdb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mongo_initdb_root_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -93,14 +93,13 @@ function create_standalone_mongodb($environment_id, $destination_uuid, ?array $o
return $database;
}
-function create_standalone_mysql($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMysql
+function create_standalone_mysql($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMysql
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMysql;
$database->uuid = (new Cuid2);
$database->name = 'mysql-database-'.$database->uuid;
- $database->mysql_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
- $database->mysql_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mysql_root_password = Str::password(length: 64, symbols: false);
+ $database->mysql_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -112,14 +111,13 @@ function create_standalone_mysql($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_mariadb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneMariadb
+function create_standalone_mariadb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneMariadb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneMariadb;
$database->uuid = (new Cuid2);
$database->name = 'mariadb-database-'.$database->uuid;
- $database->mariadb_root_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
- $database->mariadb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->mariadb_root_password = Str::password(length: 64, symbols: false);
+ $database->mariadb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -131,13 +129,12 @@ function create_standalone_mariadb($environment_id, $destination_uuid, ?array $o
return $database;
}
-function create_standalone_keydb($environment_id, $destination_uuid, ?array $otherData = null): StandaloneKeydb
+function create_standalone_keydb($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneKeydb
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneKeydb;
$database->uuid = (new Cuid2);
$database->name = 'keydb-database-'.$database->uuid;
- $database->keydb_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->keydb_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -149,13 +146,12 @@ function create_standalone_keydb($environment_id, $destination_uuid, ?array $oth
return $database;
}
-function create_standalone_dragonfly($environment_id, $destination_uuid, ?array $otherData = null): StandaloneDragonfly
+function create_standalone_dragonfly($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneDragonfly
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneDragonfly;
$database->uuid = (new Cuid2);
$database->name = 'dragonfly-database-'.$database->uuid;
- $database->dragonfly_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->dragonfly_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -167,13 +163,12 @@ function create_standalone_dragonfly($environment_id, $destination_uuid, ?array
return $database;
}
-function create_standalone_clickhouse($environment_id, $destination_uuid, ?array $otherData = null): StandaloneClickhouse
+function create_standalone_clickhouse($environment_id, StandaloneDocker|SwarmDocker $destination, ?array $otherData = null): StandaloneClickhouse
{
- $destination = StandaloneDocker::where('uuid', $destination_uuid)->firstOrFail();
$database = new StandaloneClickhouse;
$database->uuid = (new Cuid2);
$database->name = 'clickhouse-database-'.$database->uuid;
- $database->clickhouse_admin_password = \Illuminate\Support\Str::password(length: 64, symbols: false);
+ $database->clickhouse_admin_password = Str::password(length: 64, symbols: false);
$database->environment_id = $environment_id;
$database->destination_id = $destination->id;
$database->destination_type = $destination->getMorphClass();
@@ -279,7 +274,7 @@ function removeOldBackups($backup): void
->whereNull('s3_uploaded')
->delete();
- } catch (\Exception $e) {
+ } catch (Exception $e) {
throw $e;
}
}
@@ -345,7 +340,7 @@ function deleteOldBackupsLocally($backup): Collection
$processedBackups = collect();
$server = null;
- if ($backup->database_type === \App\Models\ServiceDatabase::class) {
+ if ($backup->database_type === ServiceDatabase::class) {
$server = $backup->database->service->server;
} else {
$server = $backup->database->destination->server;
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 011c149c9..9f0f2cd73 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -18,6 +18,7 @@
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneClickhouse;
+use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
@@ -25,6 +26,7 @@
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
+use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
@@ -155,6 +157,73 @@ function validateShellSafePath(string $input, string $context = 'path'): string
return $input;
}
+/**
+ * Validate that a filename is safe for use as a plain file name (no path components).
+ *
+ * Prevents path traversal attacks by rejecting directory separators, traversal
+ * sequences, and null bytes, in addition to all shell metacharacters blocked by
+ * validateShellSafePath(). Intended for user-supplied filenames such as PostgreSQL
+ * init script names that are later written to a specific directory on the host.
+ *
+ * @param string $input The filename to validate
+ * @param string $context Descriptive name for error messages (e.g., 'init script filename')
+ * @return string The validated input (unchanged if valid)
+ *
+ * @throws Exception If dangerous characters or path traversal sequences are detected
+ */
+function validateFilenameSafe(string $input, string $context = 'filename'): string
+{
+ // First apply shell-metachar checks
+ validateShellSafePath($input, $context);
+
+ // Reject NUL bytes (can be used to truncate path strings in some contexts)
+ if (str_contains($input, "\0")) {
+ throw new Exception(
+ "Invalid {$context}: contains null byte. ".
+ 'Null bytes are not allowed in filenames for security reasons.'
+ );
+ }
+
+ // Reject directory separators — filename must be a single path component
+ if (str_contains($input, '/') || str_contains($input, '\\')) {
+ throw new Exception(
+ "Invalid {$context}: directory separators ('/' or '\\') are not allowed. ".
+ 'Provide a plain filename without path components.'
+ );
+ }
+
+ // Reject path traversal sequences (catches encoded or unusual forms)
+ if (str_contains($input, '..')) {
+ throw new Exception(
+ "Invalid {$context}: path traversal sequence ('..') is not allowed."
+ );
+ }
+
+ // Reject shell globbing / expansion metacharacters and whitespace that would
+ // split the filename into additional shell arguments if ever interpolated
+ // unquoted (defence in depth on top of escapeshellarg() at call sites).
+ $shellExpansionChars = [
+ ' ' => 'whitespace',
+ '*' => 'glob wildcard',
+ '?' => 'glob wildcard',
+ '[' => 'glob character class',
+ ']' => 'glob character class',
+ '~' => 'tilde expansion',
+ '"' => 'double quote',
+ "'" => 'single quote',
+ ];
+
+ foreach ($shellExpansionChars as $char => $description) {
+ if (str_contains($input, $char)) {
+ throw new Exception(
+ "Invalid {$context}: contains forbidden character '{$char}' ({$description})."
+ );
+ }
+ }
+
+ return $input;
+}
+
/**
* Validate that a databases_to_backup input string is safe from command injection.
*
@@ -259,6 +328,16 @@ function currentTeam()
return Auth::user()?->currentTeam() ?? null;
}
+function find_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
+{
+ if (blank($uuid) || ! currentTeam()) {
+ return null;
+ }
+
+ return StandaloneDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first()
+ ?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
+}
+
function showBoarding(): bool
{
if (isDev()) {
@@ -3453,10 +3532,10 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
- // Return wire:navigate.hover for SPA navigation with prefetching, or empty string if disabled
- return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate.hover' : '';
+ // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
+ return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
- return 'wire:navigate.hover';
+ return 'wire:navigate';
}
}
@@ -3489,34 +3568,6 @@ function getHelperVersion(): string
return config('constants.coolify.helper_version');
}
-function loadConfigFromGit(string $repository, string $branch, string $base_directory, int $server_id, int $team_id)
-{
- $server = Server::find($server_id)->where('team_id', $team_id)->first();
- if (! $server) {
- return;
- }
- $uuid = new Cuid2;
- $cloneCommand = "git clone --no-checkout -b $branch $repository .";
- $workdir = rtrim($base_directory, '/');
- $fileList = collect([".$workdir/coolify.json"]);
- $commands = collect([
- "rm -rf /tmp/{$uuid}",
- "mkdir -p /tmp/{$uuid}",
- "cd /tmp/{$uuid}",
- $cloneCommand,
- 'git sparse-checkout init --cone',
- "git sparse-checkout set {$fileList->implode(' ')}",
- 'git read-tree -mu HEAD',
- "cat .$workdir/coolify.json",
- 'rm -rf /tmp/{$uuid}',
- ]);
- try {
- return instant_remote_process($commands, $server);
- } catch (Exception) {
- // continue
- }
-}
-
function loggy($message = null, array $context = [])
{
if (! isDev()) {
diff --git a/config/constants.php b/config/constants.php
index 743b5e38c..f2f6946fb 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,9 +2,9 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.474',
+ 'version' => '4.0.0',
'helper_version' => '1.0.13',
- 'realtime_version' => '1.0.13',
+ 'realtime_version' => '1.0.14',
'self_hosted' => env('SELF_HOSTED', true),
'autoupdate' => env('AUTOUPDATE'),
'base_config_path' => env('BASE_CONFIG_PATH', '/data/coolify'),
diff --git a/config/deprecations.php b/config/deprecations.php
new file mode 100644
index 000000000..551b562fa
--- /dev/null
+++ b/config/deprecations.php
@@ -0,0 +1,5 @@
+ 'Docker Swarm is deprecated and will be removed in Coolify v5. Coolify v5 will be replacing Swarm with native Docker Compose replicas and our own scaling solution. Existing Swarm deployments will continue to work on v4 as-is. We do not recommend setting up new Swarm deployments for the time being.',
+];
diff --git a/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php
new file mode 100644
index 000000000..47ee6e30a
--- /dev/null
+++ b/database/migrations/2026_04_19_000000_backfill_and_encrypt_webhook_secrets.php
@@ -0,0 +1,59 @@
+text($col)->nullable()->change();
+ }
+ });
+
+ try {
+ DB::table('applications')->chunkById(100, function ($apps) use ($columns) {
+ foreach ($apps as $app) {
+ $updates = [];
+ foreach ($columns as $col) {
+ $current = $app->{$col};
+
+ if (empty($current)) {
+ $updates[$col] = Crypt::encryptString(Str::random(40));
+
+ continue;
+ }
+
+ try {
+ Crypt::decryptString($current);
+
+ continue;
+ } catch (Exception) {
+ // Not encrypted yet
+ }
+
+ $updates[$col] = Crypt::encryptString($current);
+ }
+ if ($updates !== []) {
+ DB::table('applications')->where('id', $app->id)->update($updates);
+ }
+ }
+ });
+ } catch (Exception $e) {
+ echo 'Backfilling and encrypting webhook secrets failed.';
+ echo $e->getMessage();
+ }
+ }
+}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/docker-compose.windows.yml
+++ b/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/docker/coolify-realtime/package-lock.json b/docker/coolify-realtime/package-lock.json
index 174077562..eae81be6a 100644
--- a/docker/coolify-realtime/package-lock.json
+++ b/docker/coolify-realtime/package-lock.json
@@ -165,9 +165,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
diff --git a/docker/coolify-realtime/terminal-server.js b/docker/coolify-realtime/terminal-server.js
index 3ae77857f..f5760279f 100755
--- a/docker/coolify-realtime/terminal-server.js
+++ b/docker/coolify-realtime/terminal-server.js
@@ -105,9 +105,25 @@ const verifyClient = async (info, callback) => {
const wss = new WebSocketServer({ server, path: '/terminal/ws', verifyClient: verifyClient });
+const HEARTBEAT_INTERVAL_MS = 30000;
+const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
+
wss.on('connection', async (ws, req) => {
+ ws.isAlive = true;
+ ws.on('pong', () => { ws.isAlive = true; });
+
const userId = generateUserId();
- const userSession = { ws, userId, ptyProcess: null, isActive: false, authorizedIPs: [] };
+ ws.userId = userId;
+ const userSession = {
+ ws,
+ userId,
+ ptyProcess: null,
+ isActive: false,
+ authorizedIPs: [],
+ lastActivityAt: Date.now(),
+ authReady: false,
+ pendingMessages: [],
+ };
const { xsrfToken, laravelSession, sessionCookieName } = getSessionCookie(req);
const connectionContext = {
userId,
@@ -117,6 +133,26 @@ wss.on('connection', async (ws, req) => {
hasLaravelSession: Boolean(laravelSession),
};
+ // Register socket handlers up front so messages sent immediately by the client
+ // (e.g. a command replay on reconnect) are not dropped while the auth/IP fetch
+ // below is still pending.
+ ws.on('message', (message) => {
+ if (userSession.authReady) {
+ handleMessage(userSession, message);
+ } else {
+ userSession.pendingMessages.push(message);
+ }
+ });
+ ws.on('error', (err) => handleError(err, userId));
+ ws.on('close', (code, reason) => {
+ logTerminal('log', 'Terminal websocket connection closed.', {
+ userId,
+ code,
+ reason: reason?.toString(),
+ });
+ handleClose(userId);
+ });
+
// Verify presence of required tokens
if (!laravelSession || !xsrfToken) {
logTerminal('warn', 'Closing websocket connection because required auth tokens are missing.', connectionContext);
@@ -148,28 +184,66 @@ wss.on('connection', async (ws, req) => {
}
userSessions.set(userId, userSession);
+ userSession.authReady = true;
logTerminal('log', 'Terminal websocket connection established.', {
...connectionContext,
authorizedHostCount: userSession.authorizedIPs.length,
+ bufferedMessages: userSession.pendingMessages.length,
});
- ws.on('message', (message) => {
- handleMessage(userSession, message);
- });
- ws.on('error', (err) => handleError(err, userId));
- ws.on('close', (code, reason) => {
- logTerminal('log', 'Terminal websocket connection closed.', {
- userId,
- code,
- reason: reason?.toString(),
- });
- handleClose(userId);
- });
+ // Drain any messages that arrived while we were waiting on the IP auth call.
+ while (userSession.pendingMessages.length > 0) {
+ handleMessage(userSession, userSession.pendingMessages.shift());
+ }
});
+const heartbeat = setInterval(() => {
+ wss.clients.forEach((ws) => {
+ if (ws.isAlive === false) {
+ logTerminal('warn', 'Terminating WS due to missed protocol pong.');
+ return ws.terminate();
+ }
+ ws.isAlive = false;
+ try {
+ ws.ping();
+ } catch (_) {
+ // ignore — close handler will follow
+ }
+
+ const session = ws.userId ? userSessions.get(ws.userId) : null;
+ if (session?.isActive && session.lastActivityAt && (Date.now() - session.lastActivityAt > IDLE_TIMEOUT_MS)) {
+ const idleMs = Date.now() - session.lastActivityAt;
+ logTerminal('warn', 'Closing terminal session due to idle timeout.', {
+ userId: ws.userId,
+ idleMs,
+ idleTimeoutMs: IDLE_TIMEOUT_MS,
+ });
+ try {
+ ws.send('idle-timeout');
+ } catch (_) {
+ // ignore — close still attempted below
+ }
+ killPtyProcess(ws.userId);
+ setTimeout(() => {
+ try {
+ ws.close(1000, 'Idle timeout');
+ } catch (_) {
+ // ignore — already closed
+ }
+ }, 100);
+ }
+ });
+}, HEARTBEAT_INTERVAL_MS);
+
+wss.on('close', () => clearInterval(heartbeat));
+
const messageHandlers = {
- message: (session, data) => session.ptyProcess.write(data),
+ message: (session, data) => {
+ session.lastActivityAt = Date.now();
+ session.ptyProcess.write(data);
+ },
resize: (session, { cols, rows }) => {
+ session.lastActivityAt = Date.now();
cols = cols > 0 ? cols : 80;
rows = rows > 0 ? rows : 30;
session.ptyProcess.resize(cols, rows)
@@ -197,12 +271,6 @@ function handleMessage(userSession, message) {
return;
}
- logTerminal('log', 'Received websocket message.', {
- userId: userSession.userId,
- keys: Object.keys(parsed),
- isActive: userSession.isActive,
- });
-
Object.entries(parsed).forEach(([key, value]) => {
const handler = messageHandlers[key];
if (handler && (userSession.isActive || key === 'checkActive' || key === 'command' || key === 'ping')) {
@@ -301,6 +369,7 @@ async function handleCommand(ws, command, userId) {
userSession.ptyProcess = ptyProcess;
userSession.isActive = true;
+ userSession.lastActivityAt = Date.now();
ws.send('pty-ready');
diff --git a/openapi.json b/openapi.json
index 1acb4d73b..d8557e607 100644
--- a/openapi.json
+++ b/openapi.json
@@ -3793,6 +3793,70 @@
]
}
},
+ "\/applications\/{uuid}\/previews\/{pull_request_id}": {
+ "delete": {
+ "tags": [
+ "Applications"
+ ],
+ "summary": "Delete Preview Deployment",
+ "description": "Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes\/networks, and deletes the preview record.",
+ "operationId": "delete-preview-deployment-by-pull-request-id",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "description": "UUID of the application.",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "pull_request_id",
+ "in": "path",
+ "description": "Pull request ID of the preview to delete.",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Preview deletion queued.",
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "message": {
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "400": {
+ "$ref": "#\/components\/responses\/400"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "$ref": "#\/components\/responses\/422"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/cloud-tokens": {
"get": {
"tags": [
diff --git a/openapi.yaml b/openapi.yaml
index 6b16e494e..df2515b06 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2398,6 +2398,48 @@ paths:
security:
-
bearerAuth: []
+ '/applications/{uuid}/previews/{pull_request_id}':
+ delete:
+ tags:
+ - Applications
+ summary: 'Delete Preview Deployment'
+ description: 'Delete a preview deployment for a pull request. Cancels active deployments, stops containers, removes volumes/networks, and deletes the preview record.'
+ operationId: delete-preview-deployment-by-pull-request-id
+ parameters:
+ -
+ name: uuid
+ in: path
+ description: 'UUID of the application.'
+ required: true
+ schema:
+ type: string
+ -
+ name: pull_request_id
+ in: path
+ description: 'Pull request ID of the preview to delete.'
+ required: true
+ schema:
+ type: integer
+ responses:
+ '200':
+ description: 'Preview deletion queued.'
+ content:
+ application/json:
+ schema:
+ properties:
+ message: { type: string }
+ type: object
+ '401':
+ $ref: '#/components/responses/401'
+ '400':
+ $ref: '#/components/responses/400'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ $ref: '#/components/responses/422'
+ security:
+ -
+ bearerAuth: []
/cloud-tokens:
get:
tags:
diff --git a/other/nightly/docker-compose.prod.yml b/other/nightly/docker-compose.prod.yml
index 901aeb833..56c5b416b 100644
--- a/other/nightly/docker-compose.prod.yml
+++ b/other/nightly/docker-compose.prod.yml
@@ -60,7 +60,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.13'
+ image: '${REGISTRY_URL:-ghcr.io}/coollabsio/coolify-realtime:1.0.14'
ports:
- "${SOKETI_PORT:-6001}:6001"
- "6002:6002"
diff --git a/other/nightly/docker-compose.windows.yml b/other/nightly/docker-compose.windows.yml
index 998d35974..e1c09c64c 100644
--- a/other/nightly/docker-compose.windows.yml
+++ b/other/nightly/docker-compose.windows.yml
@@ -96,7 +96,7 @@ services:
retries: 10
timeout: 2s
soketi:
- image: 'ghcr.io/coollabsio/coolify-realtime:1.0.13'
+ image: 'ghcr.io/coollabsio/coolify-realtime:1.0.14'
pull_policy: always
container_name: coolify-realtime
restart: always
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 27d911c67..3307b7f2e 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,7 +1,7 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.474"
+ "version": "4.0.0"
},
"nightly": {
"version": "4.0.0"
diff --git a/package-lock.json b/package-lock.json
index 1fcd7cc1e..20aa0e822 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1781,9 +1781,9 @@
}
},
"node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"dev": true,
"funding": [
{
diff --git a/public/svgs/cap-captcha.png b/public/svgs/cap-captcha.png
new file mode 100644
index 000000000..4b6a7df14
Binary files /dev/null and b/public/svgs/cap-captcha.png differ
diff --git a/resources/js/terminal.js b/resources/js/terminal.js
index aa5f37353..7a7fc8536 100644
--- a/resources/js/terminal.js
+++ b/resources/js/terminal.js
@@ -42,6 +42,10 @@ export function initializeTerminalComponent() {
maxHeartbeatMisses: 3,
// Command buffering for race condition prevention
pendingCommand: null,
+ // Last successfully sent SSH command — replayed after a transient reconnect
+ // so the PTY respawns automatically. Cleared on intentional terminations
+ // (pty-exited, idle-timeout, unprocessable).
+ lastSentCommand: null,
// Resize handling
resizeObserver: null,
resizeTimeout: null,
@@ -75,8 +79,6 @@ export function initializeTerminalComponent() {
focusWhenReady();
});
- this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
-
this.$watch('terminalActive', (active) => {
if (!active && this.keepAliveInterval) {
clearInterval(this.keepAliveInterval);
@@ -150,8 +152,11 @@ export function initializeTerminalComponent() {
},
clearAllTimers() {
- [this.keepAliveInterval, this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
- .forEach(timer => timer && clearInterval(timer));
+ if (this.keepAliveInterval) {
+ clearInterval(this.keepAliveInterval);
+ }
+ [this.reconnectInterval, this.connectionTimeoutId, this.pingTimeoutId, this.resizeTimeout]
+ .forEach(timer => timer && clearTimeout(timer));
this.keepAliveInterval = null;
this.reconnectInterval = null;
this.connectionTimeoutId = null;
@@ -161,9 +166,17 @@ export function initializeTerminalComponent() {
resetTerminal() {
if (this.term) {
- this.$wire.dispatch('error', 'Terminal websocket connection lost.');
- this.term.reset();
- this.term.clear();
+ this.$wire.dispatch('error', 'Terminal websocket connection lost. Reconnecting...');
+ // Preserve scrollback so the user keeps the context of their previous
+ // session. Print a visible marker so they know where the disconnect
+ // happened. Old PTY shell state cannot be restored — this is purely
+ // a visual carry-over.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[33m── Connection lost at ${stamp}, reconnecting... ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — terminal not ready to receive writes
+ }
this.pendingWrites = 0;
this.paused = false;
this.commandBuffer = '';
@@ -276,10 +289,22 @@ export function initializeTerminalComponent() {
this.connectionTimeoutId = null;
}
- // Flush any buffered command from before WebSocket was ready
+ // Flush any buffered command from before WebSocket was ready, otherwise
+ // replay the last command so a transient reconnect respawns the PTY
+ // automatically without requiring the user to click Connect again.
if (this.pendingCommand) {
this.sendMessage(this.pendingCommand);
this.pendingCommand = null;
+ } else if (this.lastSentCommand) {
+ logTerminal('log', '[Terminal] Replaying last command after reconnect.');
+ this.sendMessage(this.lastSentCommand);
+ }
+
+ // (Re)start application-level keepalive on every successful connect.
+ // Server-side WebSocket protocol pings are the primary heartbeat; this
+ // adds a JSON-level ping in case the server-side is older or restarting.
+ if (!this.keepAliveInterval) {
+ this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000);
}
// Start ping timeout monitoring
@@ -354,6 +379,9 @@ export function initializeTerminalComponent() {
sendMessage(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
+ if (message && message.command) {
+ this.lastSentCommand = message;
+ }
} else {
logTerminal('warn', '[Terminal] WebSocket not ready, message not sent:', message);
}
@@ -368,8 +396,6 @@ export function initializeTerminalComponent() {
},
handleSocketMessage(event) {
- logTerminal('log', '[Terminal] Received WebSocket message:', event.data);
-
// Handle pong responses
if (event.data === 'pong') {
this.heartbeatMissed = 0;
@@ -387,7 +413,15 @@ export function initializeTerminalComponent() {
this.term.open(document.getElementById('terminal'));
this.term._initialized = true;
} else {
- this.term.reset();
+ // Already initialized — this is a reconnect or a follow-up command.
+ // Preserve scrollback so the user keeps context. Write a visible
+ // separator so the new shell prompt is easy to spot.
+ try {
+ const stamp = new Date().toLocaleTimeString();
+ this.term.write(`\r\n\x1b[32m── Reconnected at ${stamp} ──\x1b[0m\r\n`);
+ } catch (_) {
+ // ignore — fall through; xterm will render the new prompt anyway
+ }
}
this.terminalActive = true;
this.term.focus();
@@ -415,6 +449,7 @@ export function initializeTerminalComponent() {
} else if (event.data === 'unprocessable') {
if (this.term) this.term.reset();
this.terminalActive = false;
+ this.lastSentCommand = null;
this.message = '(sorry, something went wrong, please try again)';
// Notify parent component that terminal connection failed
@@ -423,9 +458,19 @@ export function initializeTerminalComponent() {
this.terminalActive = false;
this.term.reset();
this.commandBuffer = '';
+ this.lastSentCommand = null;
// Notify parent component that terminal disconnected
this.$wire.dispatch('terminalDisconnected');
+ } else if (event.data === 'idle-timeout') {
+ this.$wire.dispatch('error', 'Terminal closed after 30 minutes of inactivity.');
+ this.terminalActive = false;
+ if (this.term) {
+ this.term.reset();
+ }
+ this.commandBuffer = '';
+ this.lastSentCommand = null;
+ this.$wire.dispatch('terminalDisconnected');
} else if (
typeof event.data === 'string' &&
(event.data.startsWith('Unauthorized:') || event.data.startsWith('Invalid SSH command:'))
@@ -494,11 +539,6 @@ export function initializeTerminalComponent() {
},
keepAlive() {
- // Skip keepalive when document is hidden to prevent unnecessary disconnects
- if (!this.isDocumentVisible) {
- return;
- }
-
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.sendMessage({ ping: true });
} else if (this.connectionState === 'disconnected') {
@@ -524,10 +564,23 @@ export function initializeTerminalComponent() {
logTerminal('log', '[Terminal] Tab visible, resuming connection management');
if (this.wasConnectedBeforeHidden && this.socket && this.socket.readyState === WebSocket.OPEN) {
- // Send immediate ping to verify connection is still alive
+ // Connection may be half-open after Cloudflare/proxy idle drop while hidden.
+ // Probe with a short timeout (5s) instead of the default 35s — force a
+ // reconnect quickly if no pong arrives so the user is not stuck typing
+ // into a dead socket.
this.heartbeatMissed = 0;
this.sendMessage({ ping: true });
- this.resetPingTimeout();
+ if (this.pingTimeoutId) {
+ clearTimeout(this.pingTimeoutId);
+ }
+ this.pingTimeoutId = setTimeout(() => {
+ logTerminal('warn', '[Terminal] Visibility-resume ping timed out, forcing reconnect.');
+ try {
+ this.socket.close(4000, 'Visibility-resume timeout');
+ } catch (_) {
+ // ignore — close handler will run on its own
+ }
+ }, 5000);
} else if (this.wasConnectedBeforeHidden && this.connectionState !== 'connected') {
// Was connected before but now disconnected - attempt reconnection
this.reconnectAttempts = 0;
diff --git a/resources/views/components/deprecated-badge.blade.php b/resources/views/components/deprecated-badge.blade.php
new file mode 100644
index 000000000..9a797048d
--- /dev/null
+++ b/resources/views/components/deprecated-badge.blade.php
@@ -0,0 +1,6 @@
+merge(['class' => 'inline-flex items-center']) }}>
+
+ Deprecated
+
+
diff --git a/resources/views/components/helper.blade.php b/resources/views/components/helper.blade.php
index 394f6275f..2542839f1 100644
--- a/resources/views/components/helper.blade.php
+++ b/resources/views/components/helper.blade.php
@@ -1,4 +1,5 @@
-