Merge remote-tracking branch 'origin/next' into feat/railpack

This commit is contained in:
Andras Bacsai committed 2026-04-28 14:36:54 +02:00
commit 5cef7cc092
153 files changed
+5603 -773

No files matched your search

+1 -1
View File
@@ -51,7 +51,7 @@ class StartClickhouse
],
'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,
+1 -1
View File
@@ -107,7 +107,7 @@ class StartDragonfly
],
'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,
+2 -2
View File
@@ -109,7 +109,7 @@ class StartKeydb
],
'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 @@ class StartKeydb
$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'] ?? [],
[
+1 -1
View File
@@ -175,7 +175,7 @@ class StartMariadb
);
}
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'],
[
+4 -1
View File
@@ -340,7 +340,10 @@ class StartMongodb
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";
+3 -2
View File
@@ -175,7 +175,7 @@ class StartMysql
);
}
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 @@ class StartMysql
$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.'";
+14 -7
View File
@@ -111,10 +111,7 @@ class StartPostgresql
],
'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 @@ class StartPostgresql
$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 @@ class StartPostgresql
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;
}
}
+1 -1
View File
@@ -181,7 +181,7 @@ class StartRedis
);
}
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',
+1 -1
View File
@@ -48,7 +48,7 @@ class CleanupDocker
);
$commands = [
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true" --filter "label!=coolify.type=database" --filter "label!=coolify.type=application" --filter "label!=coolify.type=service"',
$imagePruneCmd,
'docker builder prune -af',
'docker buildx prune --builder coolify-railpack -af 2>/dev/null || true',
+20 -10
View File
@@ -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 @@ class ViewScheduledLogs extends Command
$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 @@ class ViewScheduledLogs extends Command
$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");
}
}
}
+3
View File
@@ -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 @@ class Kernel extends ConsoleKernel
// $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
@@ -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\Enums\BuildPackTypes;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\LocalFileVolume;
@@ -1058,7 +1060,7 @@ class ApplicationsController extends Controller
$connectToDockerNetwork = $request->connect_to_docker_network;
$customNginxConfiguration = $request->custom_nginx_configuration;
$isContainerLabelEscapeEnabled = $request->boolean('is_container_label_escape_enabled', true);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled',false);
$isPreserveRepositoryEnabled = $request->boolean('is_preserve_repository_enabled', false);
if (! is_null($customNginxConfiguration)) {
if (! isBase64Encoded($customNginxConfiguration)) {
@@ -4119,7 +4121,7 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
'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 @@ class ApplicationsController extends Controller
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.']);
}
}
@@ -379,9 +379,9 @@ class DatabasesController extends Controller
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 @@ class DatabasesController extends Controller
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 @@ class DatabasesController extends Controller
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 @@ class DatabasesController extends Controller
$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 @@ class DatabasesController extends Controller
$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 @@ class DatabasesController extends Controller
case 'standalone-mysql':
$allowedFields = ['name', 'description', 'image', 'public_port', 'public_port_timeout', 'is_public', 'instant_deploy', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'mysql_root_password', 'mysql_password', 'mysql_user', 'mysql_database', 'mysql_conf'];
$validator = customApiValidator($request->all(), [
'mysql_root_password' => 'string',
'mysql_password' => 'string',
'mysql_user' => 'string',
'mysql_database' => 'string',
'mysql_root_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_password' => ValidationPatterns::databasePasswordRules(required: false),
'mysql_user' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_database' => ValidationPatterns::databaseIdentifierRules(required: false),
'mysql_conf' => 'string',
]);
if ($request->has('mysql_conf')) {
@@ -639,10 +639,10 @@ class DatabasesController extends Controller
'backup_now' => ['type' => 'boolean', 'description' => 'Whether to trigger backup immediately after creation'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Number of backups to retain locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Number of days to retain backups locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage (MB) for local backups'],
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage (GB) for local backups'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Number of backups to retain in S3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Number of days to retain backups in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage (MB) for S3 backups'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage (GB) for S3 backups'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -703,10 +703,10 @@ class DatabasesController extends Controller
'databases_to_backup' => 'string|nullable',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
@@ -747,7 +747,7 @@ class DatabasesController extends Controller
}
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 @@ class DatabasesController extends Controller
// Convert s3_storage_uuid to s3_storage_id
if (isset($backupData['s3_storage_uuid'])) {
$s3Storage = S3Storage::ownedByCurrentTeam()->where('uuid', $backupData['s3_storage_uuid'])->first();
$s3Storage = S3Storage::ownedByCurrentTeamAPI($teamId)->where('uuid', $backupData['s3_storage_uuid'])->first();
if ($s3Storage) {
$backupData['s3_storage_id'] = $s3Storage->id;
} elseif ($request->boolean('save_s3')) {
@@ -878,10 +878,10 @@ class DatabasesController extends Controller
'frequency' => ['type' => 'string', 'description' => 'Frequency of the backup'],
'database_backup_retention_amount_locally' => ['type' => 'integer', 'description' => 'Retention amount of the backup locally'],
'database_backup_retention_days_locally' => ['type' => 'integer', 'description' => 'Retention days of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'integer', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_max_storage_locally' => ['type' => 'number', 'description' => 'Max storage of the backup locally'],
'database_backup_retention_amount_s3' => ['type' => 'integer', 'description' => 'Retention amount of the backup in s3'],
'database_backup_retention_days_s3' => ['type' => 'integer', 'description' => 'Retention days of the backup in s3'],
'database_backup_retention_max_storage_s3' => ['type' => 'integer', 'description' => 'Max storage of the backup in S3'],
'database_backup_retention_max_storage_s3' => ['type' => 'number', 'description' => 'Max storage of the backup in S3'],
'timeout' => ['type' => 'integer', 'description' => 'Backup job timeout in seconds (min: 60, max: 36000)', 'default' => 3600],
],
),
@@ -933,10 +933,10 @@ class DatabasesController extends Controller
'frequency' => 'string',
'database_backup_retention_amount_locally' => 'integer|min:0',
'database_backup_retention_days_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'integer|min:0',
'database_backup_retention_max_storage_locally' => 'numeric|min:0',
'database_backup_retention_amount_s3' => 'integer|min:0',
'database_backup_retention_days_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'integer|min:0',
'database_backup_retention_max_storage_s3' => 'numeric|min:0',
'timeout' => 'integer|min:60|max:36000',
]);
if ($validator->fails()) {
@@ -982,7 +982,7 @@ class DatabasesController extends Controller
], 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 @@ class DatabasesController extends Controller
// 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 @@ class DatabasesController extends Controller
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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
}
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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
], 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 @@ class DatabasesController extends Controller
$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 @@ class DatabasesController extends Controller
}
$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 @@ class DatabasesController extends Controller
} 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 @@ class DatabasesController extends Controller
'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 @@ class DatabasesController extends Controller
'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 @@ class DatabasesController extends Controller
'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',
]);
@@ -121,7 +121,7 @@ class HetznerController extends Controller
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 @@ class HetznerController extends Controller
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 @@ class HetznerController extends Controller
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 @@ class HetznerController extends Controller
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 @@ class HetznerController extends Controller
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);
}
}
}
+7 -3
View File
@@ -147,11 +147,15 @@ class OtherController extends Controller
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' => []],
]);
}
@@ -2018,7 +2018,7 @@ class ServicesController extends Controller
'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 @@ class ServicesController extends Controller
'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',
]);
+23 -7
View File
@@ -6,8 +6,8 @@ use App\Events\TestEvent;
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 @@ class Controller extends BaseController
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 @@ class Controller extends BaseController
} 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]);
+7 -2
View File
@@ -19,7 +19,12 @@ class OauthController extends Controller
{
try {
$oauthUser = get_socialite_provider($provider)->user();
$user = User::whereEmail($oauthUser->email)->first();
$email = trim((string) $oauthUser->email);
if ($email === '') {
abort(403, 'OAuth provider did not return an email address');
}
$email = strtolower($email);
$user = User::whereEmail($email)->first();
if (! $user) {
$settings = instanceSettings();
if (! $settings->is_registration_enabled) {
@@ -28,7 +33,7 @@ class OauthController extends Controller
$user = User::create([
'name' => $oauthUser->name,
'email' => $oauthUser->email,
'email' => $email,
]);
}
Auth::login($user);
+69 -25
View File
@@ -11,6 +11,26 @@ use Pion\Laravel\ChunkUpload\Receiver\FileReceiver;
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 @@ class UploadController extends BaseController
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 @@ class UploadController extends BaseController
'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 @@ class UploadController extends BaseController
]);
}
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';
}
}
+21 -2
View File
@@ -57,10 +57,29 @@ class Bitbucket extends Controller
}
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,
+9
View File
@@ -67,6 +67,15 @@ class Gitea extends Controller
}
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([
+9
View File
@@ -81,6 +81,15 @@ class Github extends Controller
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([
+10 -1
View File
@@ -100,7 +100,16 @@ class Gitlab extends Controller
}
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',
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Jobs;
use App\Models\PersonalAccessToken;
use App\Models\Team;
use App\Models\User;
use App\Notifications\ApiTokenExpiringNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\RateLimiter;
use Laravel\Horizon\Contracts\Silenced;
class ApiTokenExpirationWarningJob implements ShouldBeEncrypted, ShouldQueue, Silenced
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public $timeout = 120;
public function handle(): void
{
PersonalAccessToken::query()
->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,
);
}
});
}
}
+6 -7
View File
@@ -3396,29 +3396,28 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->execute_remote_command([executeInDocker($this->deployment_uuid, 'rm '.self::NIXPACKS_PLAN_PATH), 'hidden' => true]);
} else {
// Dockerfile buildpack
$safeNetwork = escapeshellarg($this->destination->network);
if ($this->dockerSecretsSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location}{$secrets_flags} --progress plain -t $this->build_image_name {$this->workdir}");
}
} elseif ($this->dockerBuildkitSupported) {
// BuildKit without secrets
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("DOCKER_BUILDKIT=1 docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} --progress plain -t $this->build_image_name {$this->build_args} {$this->workdir}");
}
} else {
// Traditional build with args
if ($this->force_rebuild) {
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build --no-cache {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
} else {
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} --network {$safeNetwork} -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
$build_command = $this->wrap_build_command_with_env_export("docker build {$this->buildTarget} {$this->addHosts} --network host -f {$this->workdir}{$this->dockerfile_location} {$this->build_args} -t $this->build_image_name {$this->workdir}");
}
}
$base64_build_command = base64_encode($build_command);
+18 -11
View File
@@ -43,27 +43,34 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
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 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
);
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 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
} 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 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
try {
if ($this->targetServer) {
instant_remote_process([
"rm -rf $targetCloneDir",
"rm -rf {$tgtDir}",
], $this->targetServer, false);
}
} catch (\Exception $e) {
+2 -2
View File
@@ -37,7 +37,7 @@ class Index extends Component
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
return redirect()->route('admin.index');
}
}
@@ -70,7 +70,7 @@ class Index extends Component
Auth::login($user);
refreshSession($team_to_switch_to);
return redirect(request()->header('Referer'));
return redirect()->route('dashboard');
}
private function authorizeAdminAccess(): void
+3 -13
View File
@@ -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 @@ class Show extends Component
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.');
}
+1 -1
View File
@@ -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()
+6 -6
View File
@@ -197,12 +197,12 @@ class General extends Component
'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.',
@@ -76,8 +76,12 @@ class General extends Component
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 @@ class General extends Component
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.',
@@ -89,7 +89,9 @@ class General extends Component
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 @@ class General extends Component
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.',
@@ -92,7 +92,9 @@ class General extends Component
'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 @@ class General extends Component
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.',
@@ -74,10 +74,18 @@ class General extends Component
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 @@ class General extends Component
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.',
@@ -75,9 +75,15 @@ class General extends Component
'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 @@ class General extends Component
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.',
@@ -76,10 +76,18 @@ class General extends Component
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 @@ class General extends Component
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.',
@@ -86,9 +86,15 @@ class General extends Component
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 @@ class General extends Component
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 @@ class General extends Component
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 @@ class General extends Component
$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 @@ class General extends Component
]);
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());
@@ -81,8 +81,12 @@ class General extends Component
'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 @@ class General extends Component
'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'),
]
);
}
+4 -10
View File
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
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 @@ class DockerCompose extends Component
public function submit()
{
$server_id = $this->query['server_id'];
try {
$this->validate([
'dockerComposeRaw' => 'required',
@@ -44,20 +41,17 @@ class DockerCompose extends Component
$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,
]);
+3 -8
View File
@@ -4,8 +4,6 @@ namespace App\Livewire\Project\New;
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 @@ class DockerImage extends Component
$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();
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Support\Facades\Http;
@@ -180,13 +178,10 @@ class GithubPrivateRepository extends Component
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();
@@ -7,8 +7,6 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -132,13 +130,10 @@ class GithubPrivateRepositoryDeployKey extends Component
{
$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();
@@ -7,8 +7,6 @@ use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
@@ -34,8 +32,6 @@ class PublicGitRepository extends Component
public bool $isStatic = false;
public bool $checkCoolifyConfig = true;
public ?string $publish_directory = null;
// In case of docker compose
@@ -286,16 +282,13 @@ class PublicGitRepository extends Component
throw new \RuntimeException('Invalid branch: '.$branchValidator->errors()->first('git_branch'));
}
$destination_uuid = $this->query['destination'];
$destination_uuid = $this->query['destination'] ?? null;
$project_uuid = $this->parameters['project_uuid'];
$environment_uuid = $this->parameters['environment_uuid'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
$destination = find_destination_for_current_team($destination_uuid);
if (! $destination) {
$destination = SwarmDocker::where('uuid', $destination_uuid)->first();
}
if (! $destination) {
throw new \Exception('Destination not found. What?!');
throw new \Exception('Destination not found.');
}
$destination_class = $destination->getMorphClass();
@@ -373,12 +366,6 @@ class PublicGitRepository extends Component
$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,
@@ -5,8 +5,6 @@ namespace App\Livewire\Project\New;
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 @@ CMD ["nginx", "-g", "daemon off;"]
$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();
+15 -14
View File
@@ -4,7 +4,6 @@ namespace App\Livewire\Project\Resource;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use App\Models\StandaloneDocker;
use Livewire\Component;
class Create extends Component
@@ -18,7 +17,6 @@ class Create extends Component
$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 @@ class Create extends Component
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 @@ class Create extends Component
}
$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 @@ class Create extends Component
'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 @@ class Create extends Component
});
}
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(),
];
+6 -2
View File
@@ -106,8 +106,12 @@ class Storage extends Component
$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;
@@ -58,10 +58,9 @@ class ResourceOperations extends Component
{
$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.');
+22 -7
View File
@@ -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
*
+13 -1
View File
@@ -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 @@ class ApiTokens extends Component
$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) {
+12 -2
View File
@@ -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 @@ class Index extends Component
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 @@ class Index extends Component
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],
+2 -2
View File
@@ -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 @@ class Create extends Component
'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 @@ class Create extends Component
'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.',
]
);
+2 -2
View File
@@ -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 @@ class Form extends Component
'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 @@ class Form extends Component
'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.',
]
);
+6 -2
View File
@@ -25,7 +25,9 @@ class Resources extends Component
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 @@ class Resources extends Component
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) {
+18 -5
View File
@@ -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',
+11 -3
View File
@@ -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 @@ class ApplicationPreview extends BaseModel
$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
+17
View File
@@ -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 @@ class S3Storage extends BaseModel
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 @@ class S3Storage extends BaseModel
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'],
+10
View File
@@ -90,6 +90,16 @@ class StandaloneDocker extends BaseModel
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.
+10
View File
@@ -71,6 +71,16 @@ class SwarmDocker extends BaseModel
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.
+1 -1
View File
@@ -257,7 +257,7 @@ class User extends Authenticatable implements SendsEmail
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', [
@@ -0,0 +1,103 @@
<?php
namespace App\Notifications;
use App\Models\PersonalAccessToken;
use App\Notifications\Dto\DiscordMessage;
use App\Notifications\Dto\PushoverMessage;
use App\Notifications\Dto\SlackMessage;
use Illuminate\Notifications\Messages\MailMessage;
class ApiTokenExpiringNotification extends CustomEmailNotification
{
protected string $tokenName;
protected string $expiresAt;
protected string $manageUrl;
public function __construct(public PersonalAccessToken $token)
{
$this->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 <b>{$this->tokenName}</b> expires on {$this->expiresAt}.<br/><br/>";
$message .= '<b>Action Required:</b> Rotate this token before it expires to avoid API outages.';
return new PushoverMessage(
title: 'API token expiring soon',
level: 'warning',
message: $message,
buttons: [
[
'text' => 'Manage API tokens',
'url' => $this->manageUrl,
],
],
);
}
public function toSlack(): SlackMessage
{
$description = "API token *{$this->tokenName}* expires on {$this->expiresAt}.\n\n";
$description .= "*Action Required:* Rotate this token before it expires to avoid API outages.\n\n";
$description .= "Manage tokens: {$this->manageUrl}";
return new SlackMessage(
title: '🔑 API token expiring soon',
description: $description,
color: SlackMessage::warningColor(),
);
}
}
+4
View File
@@ -54,5 +54,9 @@ class RouteServiceProvider extends ServiceProvider
RateLimiter::for('5', function (Request $request) {
return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('feedback', function (Request $request) {
return Limit::perMinute(3)->by($request->user()?->id ?: $request->ip());
});
}
}
+8 -2
View File
@@ -40,9 +40,15 @@ class SafeWebhookUrl implements ValidationRule
$host = strtolower($host);
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($host, $blockedHosts) || str_ends_with($host, '.internal')) {
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
@@ -55,7 +61,7 @@ class SafeWebhookUrl implements ValidationRule
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
if (filter_var($host, FILTER_VALIDATE_IP) && ($this->isLoopback($host) || $this->isLinkLocal($host))) {
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
+130 -8
View File
@@ -36,15 +36,31 @@ class ValidationPatterns
public const DOCKER_TARGET_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for shell-safe command strings (docker compose commands, docker run options)
* Blocks dangerous shell metacharacters: ; | ` $ ( ) > < newlines and carriage returns
* Allows & for command chaining (&&) which is common in multi-step build commands
* Allows double quotes for build args with spaces (e.g. --build-arg KEY="value")
* Blocks backslashes to prevent escape-sequence attacks
* Allows single and double quotes for quoted arguments (e.g. --entrypoint "sh -c 'npm start'")
* Uses [ \t] instead of \s to explicitly exclude \n and \r (which act as command separators)
* Token-aware pattern for shell-safe command strings (docker compose commands, docker run options).
*
* Accepts a sequence of the following tokens only:
* [ \t]+ — whitespace (space / tab)
* && — logical AND (matched before bare & can match anything)
* || — logical OR (matched before bare | can match anything)
* "[^"$`\\\n\r]*" — balanced double-quoted string; blocks $, backtick, \, newlines inside
* '[^'\n\r]*' — balanced single-quoted string; blocks newlines inside (all else literal)
* [safe-chars]+ — unquoted alphanumerics + safe path/arg chars (includes glob *, ?, and !)
*
* Blocked everywhere (outside and inside unquoted tokens):
* bare & (background op), bare |, ;, $, `, (, ), <, >, \, newline, CR
*
* Blocked inside double-quoted spans specifically:
* $ (variable/command expansion), ` (command substitution), \ (escape)
*
* Legitimate use cases preserved:
* docker compose build && docker tag x && docker push y
* make build || make clean
* rm *.tmp cp src/?.js dist/
* ! grep -q foo && echo missing
* docker compose up -d --build-arg VERSION="1.0.0"
* --entrypoint "sh -c 'npm start'"
*/
public const SHELL_SAFE_COMMAND_PATTERN = '/^[a-zA-Z0-9 \t._\-\/=:@,+\[\]{}#%^~&"\']+$/';
public const SHELL_SAFE_COMMAND_PATTERN = '/^(?:[ \t]+|&&|\|\||"[^"$`\\\\\n\r]*"|\'[^\'\n\r]*\'|[a-zA-Z0-9._\-\/=:@,+\[\]{}#%^~*?!]+)+$/';
/**
* Pattern for Docker volume names
@@ -66,6 +82,112 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
* Excludes all shell metacharacters. Max 63 chars (Postgres identifier limit).
*/
public const DB_IDENTIFIER_PATTERN = '/^[A-Za-z_][A-Za-z0-9_]{0,62}$/';
/**
* Pattern for database passwords.
* Excludes shell-dangerous characters: backtick, $, ;, |, &, <, >, \, ', ", space, newline, CR, tab, null.
* Allows a broad set of printable characters so passwords remain strong.
*/
public const DB_PASSWORD_PATTERN = '/^[A-Za-z0-9!@#%^*()_+\-=\[\]{}:,.?\/~]+$/';
/**
* Get validation rules for database identifier fields (username, database name).
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databaseIdentifierRules(bool $required = true, int $minLength = 1, int $maxLength = 63, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_IDENTIFIER_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database identifier fields.
*/
public static function databaseIdentifierMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may only contain letters, digits, and underscores, and must start with a letter or underscore.",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Get validation rules for database password fields.
*
* Set $enforcePattern to false to skip the regex check (for example when
* re-validating a legacy value on an existing record that has not been
* changed by the user). The length and type rules are always applied.
*/
public static function databasePasswordRules(bool $required = true, int $minLength = 1, int $maxLength = 128, bool $enforcePattern = true): array
{
$rules = [];
if ($required) {
$rules[] = 'required';
} else {
$rules[] = 'nullable';
}
$rules[] = 'string';
$rules[] = "min:$minLength";
$rules[] = "max:$maxLength";
if ($enforcePattern) {
$rules[] = 'regex:'.self::DB_PASSWORD_PATTERN;
}
return $rules;
}
/**
* Get validation messages for database password fields.
*/
public static function databasePasswordMessages(string $field, string $label = ''): array
{
$label = $label ?: $field;
return [
"{$field}.regex" => "The {$label} may not contain shell-unsafe characters (backtick, \$, ;, |, &, <, >, \\, quotes, spaces, or control characters).",
"{$field}.min" => "The {$label} must be at least :min character.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
/**
* Check if a string is a valid database identifier.
*/
public static function isValidDatabaseIdentifier(string $value): bool
{
return preg_match(self::DB_IDENTIFIER_PATTERN, $value) === 1;
}
/**
* Get validation rules for name fields
*/
+1
View File
@@ -19,6 +19,7 @@ trait HasNotificationSettings
'test',
'ssl_certificate_renewal',
'hetzner_deletion_failure',
'api_token_expiring',
];
/**