+
+
+
+
...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 0d9ca0153..98cce088b 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 images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
diff --git a/app/Console/Commands/Generate/Services.php b/app/Console/Commands/Generate/Services.php
index 42f9360bb..e316fc391 100644
--- a/app/Console/Commands/Generate/Services.php
+++ b/app/Console/Commands/Generate/Services.php
@@ -88,6 +88,14 @@ private function processFile(string $file): false|array
$payload['envs'] = base64_encode($envFileContent);
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
@@ -160,6 +168,14 @@ private function processFileWithFqdn(string $file): false|array
$payload['envs'] = base64_encode($modifiedEnvContent);
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
@@ -229,6 +245,14 @@ private function processFileWithFqdnRaw(string $file): false|array
$payload['envs'] = $modifiedEnvContent;
}
+ if (str($data->get('amd_only'))->toBoolean()) {
+ $payload['amd_only'] = true;
+ }
+
+ if (str($data->get('arm_only'))->toBoolean()) {
+ $payload['arm_only'] = true;
+ }
+
return $payload;
}
}
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 77f4e626f..eb2e7fc53 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;
@@ -217,7 +219,7 @@ public function applications(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -383,7 +385,7 @@ public function create_public_application(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -549,7 +551,7 @@ public function create_private_gh_app_application(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -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)) {
@@ -2397,7 +2399,7 @@ public function delete_by_uuid(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io")'],
+ 'domain' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io")'],
],
),
],
@@ -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..c05af152f 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')) {
@@ -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')) {
@@ -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 fbf4b9e56..20560635e 100644
--- a/app/Http/Controllers/Api/ServicesController.php
+++ b/app/Http/Controllers/Api/ServicesController.php
@@ -221,7 +221,7 @@ public function services(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
+ 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@@ -843,7 +843,7 @@ public function delete_by_uuid(Request $request)
type: 'object',
properties: [
'name' => ['type' => 'string', 'description' => 'The service name as defined in docker-compose.'],
- 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "http://app.coolify.io,https://app2.coolify.io").'],
+ 'url' => ['type' => 'string', 'description' => 'Comma-separated list of URLs (e.g. "https://app.coolify.io,https://app2.coolify.io").'],
],
),
],
@@ -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/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 d070cefc6..7e5025c8a 100644
--- a/app/Jobs/ApplicationDeploymentJob.php
+++ b/app/Jobs/ApplicationDeploymentJob.php
@@ -2877,7 +2877,7 @@ private function generate_healthcheck_commands()
$scheme = $this->sanitizeHealthCheckValue($this->application->health_check_scheme, '/^https?$/', 'http');
$host = $this->sanitizeHealthCheckValue($this->application->health_check_host, '/^[a-zA-Z0-9.\-_]+$/', 'localhost');
$path = $this->application->health_check_path
- ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%]+$#', '/')
+ ? $this->sanitizeHealthCheckValue($this->application->health_check_path, '#^[a-zA-Z0-9/\-_.~%,;]+$#', '/')
: null;
$url = escapeshellarg("{$scheme}://{$host}:{$health_check_port}".($path ?? '/'));
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/GlobalSearch.php b/app/Livewire/GlobalSearch.php
index 154748b47..df2adf22b 100644
--- a/app/Livewire/GlobalSearch.php
+++ b/app/Livewire/GlobalSearch.php
@@ -1496,7 +1496,10 @@ public function getServicesProperty()
'category' => 'Services',
'resourceType' => 'service',
'logo' => data_get($service, 'logo'),
- ]);
+ ] + array_filter([
+ 'amd_only' => data_get($service, 'amd_only') ? true : null,
+ 'arm_only' => data_get($service, 'arm_only') ? true : null,
+ ]));
}
$cachedServices = $items->toArray();
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 25ce82eb0..f89d16912 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 0222008b0..86e407136 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;
@@ -178,13 +176,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 f8642d6fc..5a6f288b3 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;
@@ -130,13 +128,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 dbfa15a55..b350538ac 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
@@ -284,16 +280,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();
@@ -371,12 +364,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/HealthChecks.php b/app/Livewire/Project/Shared/HealthChecks.php
index 0d5d71b45..195e7fd92 100644
--- a/app/Livewire/Project/Shared/HealthChecks.php
+++ b/app/Livewire/Project/Shared/HealthChecks.php
@@ -34,7 +34,7 @@ class HealthChecks extends Component
#[Validate(['nullable', 'integer', 'min:1', 'max:65535'])]
public ?string $healthCheckPort = null;
- #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'])]
+ #[Validate(['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'])]
public string $healthCheckPath;
#[Validate(['integer'])]
@@ -62,7 +62,7 @@ class HealthChecks extends Component
'healthCheckEnabled' => 'boolean',
'healthCheckType' => 'string|in:http,cmd',
'healthCheckCommand' => ['nullable', 'string', 'max:1000', 'regex:/^[a-zA-Z0-9 \-_.\/:=@,+]+$/'],
- 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%]+$#'],
+ 'healthCheckPath' => ['required', 'string', 'regex:#^[a-zA-Z0-9/\-_.~%,;]+$#'],
'healthCheckPort' => 'nullable|integer|min:1|max:65535',
'healthCheckHost' => ['required', 'string', 'regex:/^[a-zA-Z0-9.\-_]+$/'],
'healthCheckMethod' => 'required|string|in:GET,HEAD,POST,OPTIONS',
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/Livewire/Upgrade.php b/app/Livewire/Upgrade.php
index 7948ad6a9..1b8701d94 100644
--- a/app/Livewire/Upgrade.php
+++ b/app/Livewire/Upgrade.php
@@ -23,24 +23,42 @@ class Upgrade extends Component
public function mount()
{
- $this->currentVersion = config('constants.coolify.version');
- $this->devMode = isDev();
+ $this->refreshUpgradeState();
}
public function checkUpdate()
{
try {
- $this->latestVersion = get_latest_version_of_coolify();
- $this->currentVersion = config('constants.coolify.version');
- $this->isUpgradeAvailable = data_get(InstanceSettings::get(), 'new_version_available', false);
- if (isDev()) {
- $this->isUpgradeAvailable = true;
- }
+ $this->refreshUpgradeState();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
+ protected function refreshUpgradeState(): void
+ {
+ $this->currentVersion = config('constants.coolify.version');
+ $this->latestVersion = get_latest_version_of_coolify();
+ $this->devMode = isDev();
+
+ if ($this->devMode) {
+ $this->isUpgradeAvailable = true;
+
+ return;
+ }
+
+ $settings = InstanceSettings::find(0);
+ $hasNewerVersion = version_compare($this->latestVersion, $this->currentVersion, '>');
+ $newVersionAvailable = (bool) data_get($settings, 'new_version_available', false);
+
+ if ($settings && $newVersionAvailable && ! $hasNewerVersion) {
+ $settings->update(['new_version_available' => false]);
+ $newVersionAvailable = false;
+ }
+
+ $this->isUpgradeAvailable = $hasNewerVersion && $newVersionAvailable;
+ }
+
public function upgrade()
{
try {
diff --git a/app/Models/Application.php b/app/Models/Application.php
index fef6f6e4c..85e94bfd6 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/Team.php b/app/Models/Team.php
index 8a54a9dee..0fbcfe0c6 100644
--- a/app/Models/Team.php
+++ b/app/Models/Team.php
@@ -71,25 +71,31 @@ protected static function booted()
}
});
- static::deleting(function ($team) {
- $keys = $team->privateKeys;
- foreach ($keys as $key) {
+ static::deleting(function (Team $team) {
+ foreach ($team->privateKeys as $key) {
$key->delete();
}
- $sources = $team->sources();
- foreach ($sources as $source) {
+
+ // Transfer instance-wide sources to root team so they remain available
+ GithubApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
+ GitlabApp::where('team_id', $team->id)->where('is_system_wide', true)->update(['team_id' => 0]);
+
+ // Delete non-instance-wide sources owned by this team
+ $teamSources = GithubApp::where('team_id', $team->id)->get()
+ ->merge(GitlabApp::where('team_id', $team->id)->get());
+ foreach ($teamSources as $source) {
$source->delete();
}
- $tags = Tag::whereTeamId($team->id)->get();
- foreach ($tags as $tag) {
+
+ foreach (Tag::whereTeamId($team->id)->get() as $tag) {
$tag->delete();
}
- $shared_variables = $team->environment_variables();
- foreach ($shared_variables as $shared_variable) {
- $shared_variable->delete();
+
+ foreach ($team->environment_variables()->get() as $sharedVariable) {
+ $sharedVariable->delete();
}
- $s3s = $team->s3s;
- foreach ($s3s as $s3) {
+
+ foreach ($team->s3s as $s3) {
$s3->delete();
}
});
@@ -227,6 +233,9 @@ public function subscriptionEnded()
'is_reachable' => false,
]);
ServerReachabilityChanged::dispatch($server);
+ $server->unreachable_count = 3;
+ $server->unreachable_notification_sent = true;
+ $server->save();
}
}
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}.