diff --git a/CLAUDE.md b/CLAUDE.md index 2efe8b027..e6e2750a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,3 +68,7 @@ Laravel 12 (L10 structure), PHP 8.4, Inertia v2, React 19, Tailwind v4, PHPUnit - Use `gh` CLI for issues/PRs. - Don't change dependencies or create new base folders without approval. + +## SSH + +- All SSH commands are being run using app/Helpers/SSH.php diff --git a/app/Actions/Site/UpdateEnv.php b/app/Actions/Site/UpdateEnv.php index 0122cc61a..ecfff82f8 100644 --- a/app/Actions/Site/UpdateEnv.php +++ b/app/Actions/Site/UpdateEnv.php @@ -3,6 +3,7 @@ namespace App\Actions\Site; use App\Exceptions\SSHError; +use App\Helpers\EnvParser; use App\Models\Site; use Illuminate\Support\Facades\Validator; @@ -16,19 +17,70 @@ class UpdateEnv public function update(Site $site, array $input): void { Validator::make($input, [ - 'env' => ['required', 'string'], + 'env' => ['required_without:variables', 'nullable', 'string'], + 'variables' => ['required_without:env', 'nullable', 'array'], + 'variables.*.key' => ['required_with:variables', 'string'], + 'variables.*.value' => ['nullable', 'string'], + 'variables.*.is_secret' => ['nullable', 'boolean'], 'path' => ['nullable', 'string'], ])->validate(); $typeData = $site->type_data ?? []; $path = $input['path'] ?? data_get($typeData, 'env_path', $site->path.'/.env'); + $variables = $this->processVariables($site, $input); + + $envContent = EnvParser::stringify($variables); + $site->server->os()->write( $path, - trim((string) $input['env']), + $envContent, $site->user, ); + $site->env_variables = $variables; + $site->save(); + $site->jsonUpdate('type_data', 'env_path', $path); } + + /** + * Process incoming variables, merging with stored secrets + * + * @param array $input + * @return array + */ + private function processVariables(Site $site, array $input): array + { + if (isset($input['variables']) && is_array($input['variables'])) { + $incoming = array_map(function ($var) { + return [ + 'key' => $var['key'] ?? '', + 'value' => $var['value'] ?? '', + 'is_secret' => (bool) ($var['is_secret'] ?? false), + ]; + }, $input['variables']); + + return EnvParser::mergeWithStored($incoming, $site->env_variables); + } + + $parsed = EnvParser::parse(trim((string) $input['env'])); + + if ($site->env_variables) { + $storedMap = []; + foreach ($site->env_variables as $var) { + $storedMap[$var['key']] = $var; + } + + return array_map(function ($var) use ($storedMap) { + if (isset($storedMap[$var['key']])) { + $var['is_secret'] = $storedMap[$var['key']]['is_secret']; + } + + return $var; + }, $parsed); + } + + return $parsed; + } } diff --git a/app/Helpers/EnvParser.php b/app/Helpers/EnvParser.php new file mode 100644 index 000000000..cd8f2cc89 --- /dev/null +++ b/app/Helpers/EnvParser.php @@ -0,0 +1,163 @@ + + */ + private const SECRET_PATTERNS = ['PASSWORD', 'SECRET', 'TOKEN', 'KEY', 'PRIVATE']; + + /** + * Check if an env key should be treated as a secret + */ + public static function isSecretKey(string $key): bool + { + $upperKey = strtoupper($key); + + foreach (self::SECRET_PATTERNS as $pattern) { + if (str_contains($upperKey, $pattern)) { + return true; + } + } + + return false; + } + + /** + * Parse a raw .env string into an array of variables + * + * @return array + */ + public static function parse(string $raw): array + { + $variables = []; + $lines = explode("\n", $raw); + + foreach ($lines as $line) { + $trimmedLine = trim($line); + + // Skip empty lines and comments + if ($trimmedLine === '' || str_starts_with($trimmedLine, '#')) { + continue; + } + + // Find the first equals sign (key can't contain =, but value can) + $equalsIndex = strpos($trimmedLine, '='); + if ($equalsIndex === false) { + continue; + } + + $key = trim(substr($trimmedLine, 0, $equalsIndex)); + $value = substr($trimmedLine, $equalsIndex + 1); + + // Remove surrounding quotes if present + if ( + (str_starts_with($value, '"') && str_ends_with($value, '"')) || + (str_starts_with($value, "'") && str_ends_with($value, "'")) + ) { + $value = substr($value, 1, -1); + } + + // Handle escaped newlines in quoted strings + $value = str_replace('\\n', "\n", $value); + + if ($key !== '') { + $variables[] = [ + 'key' => $key, + 'value' => $value, + 'is_secret' => self::isSecretKey($key), + ]; + } + } + + return $variables; + } + + /** + * Convert an array of variables back to a raw .env string + * + * @param array $variables + */ + public static function stringify(array $variables): string + { + $lines = []; + + foreach ($variables as $variable) { + $key = trim($variable['key']); + $value = $variable['value']; + + if ($key === '') { + continue; + } + + $needsQuotes = str_contains($value, "\n") || + str_contains($value, ' ') || + str_contains($value, '"') || + str_contains($value, "'") || + str_contains($value, '#'); + + if ($needsQuotes) { + $escapedValue = str_replace(["\n", '"'], ['\\n', '\\"'], $value); + $lines[] = "{$key}=\"{$escapedValue}\""; + } else { + $lines[] = "{$key}={$value}"; + } + } + + return implode("\n", $lines); + } + + /** + * Mask secret values for frontend display + * Secret values are completely hidden (not sent to frontend) + * + * @param array $variables + * @return array + */ + public static function maskSecrets(array $variables): array + { + return array_map(function ($variable) { + if ($variable['is_secret']) { + $variable['value'] = ''; + } + + return $variable; + }, $variables); + } + + /** + * Merge incoming variables with stored variables + * For secrets with empty values, keep the stored value + * + * @param array $incoming + * @param array|null $stored + * @return array + */ + public static function mergeWithStored(array $incoming, ?array $stored): array + { + if ($stored === null) { + return $incoming; + } + + $storedMap = []; + foreach ($stored as $variable) { + $storedMap[$variable['key']] = $variable; + } + + return array_map(function ($variable) use ($storedMap) { + $key = $variable['key']; + $isSecret = $variable['is_secret']; + $value = $variable['value']; + + if ($isSecret && $value === '' && isset($storedMap[$key])) { + $variable['value'] = $storedMap[$key]['value']; + } + + return $variable; + }, $incoming); + } +} diff --git a/app/Http/Controllers/API/SiteController.php b/app/Http/Controllers/API/SiteController.php index 3db23bb27..2607fe006 100644 --- a/app/Http/Controllers/API/SiteController.php +++ b/app/Http/Controllers/API/SiteController.php @@ -10,6 +10,7 @@ use App\Actions\Site\UpdateLoadBalancer; use App\Actions\Site\UpdateWebDirectory; use App\Exceptions\DeploymentScriptIsEmptyException; +use App\Helpers\EnvParser; use App\Http\Controllers\Controller; use App\Http\Resources\DeploymentResource; use App\Http\Resources\SiteResource; @@ -157,9 +158,18 @@ public function showEnv(Project $project, Server $server, Site $site): JsonRespo $this->validateRoute($project, $server, $site); + $env = $site->getEnv(); + + if ($site->env_variables !== null) { + $variables = EnvParser::maskSecrets($site->env_variables); + } else { + $variables = EnvParser::parse($env); + } + return response()->json([ 'data' => [ - 'env' => $site->getEnv(), + 'env' => $env, + 'variables' => $variables, ], ]); } diff --git a/app/Http/Controllers/ApplicationController.php b/app/Http/Controllers/ApplicationController.php index 171658988..4a36a40e9 100644 --- a/app/Http/Controllers/ApplicationController.php +++ b/app/Http/Controllers/ApplicationController.php @@ -11,6 +11,7 @@ use App\Exceptions\FailedToDestroyGitHook; use App\Exceptions\SourceControlIsNotConnected; use App\Exceptions\SSHError; +use App\Helpers\EnvParser; use App\Http\Resources\DeploymentResource; use App\Http\Resources\DeploymentScriptResource; use App\Http\Resources\LoadBalancerServerResource; @@ -118,8 +119,31 @@ public function env(Request $request, Server $server, Site $site): JsonResponse $env = $site->getEnv(); + if ($site->env_variables !== null) { + $variables = EnvParser::maskSecrets($site->env_variables); + } else { + $variables = EnvParser::parse($env); + } + return response()->json([ 'env' => $env, + 'variables' => $variables, + ]); + } + + #[Post('/env/parse', name: 'application.parse-env')] + public function parseEnv(Request $request, Server $server, Site $site): JsonResponse + { + $this->authorize('view', [$site, $server]); + + $request->validate([ + 'content' => ['required', 'string'], + ]); + + $variables = EnvParser::parse($request->input('content')); + + return response()->json([ + 'variables' => $variables, ]); } diff --git a/app/Models/Site.php b/app/Models/Site.php index 6942f6d3f..b97a32717 100755 --- a/app/Models/Site.php +++ b/app/Models/Site.php @@ -27,6 +27,7 @@ * @property int $server_id * @property string $type * @property array $type_data + * @property ?array $env_variables * @property string $domain * @property array $aliases * @property string $web_directory @@ -72,6 +73,7 @@ class Site extends AbstractModel 'server_id', 'type', 'type_data', + 'env_variables', 'domain', 'aliases', 'web_directory', @@ -92,6 +94,7 @@ class Site extends AbstractModel protected $casts = [ 'server_id' => 'integer', 'type_data' => 'json', + 'env_variables' => 'encrypted:array', 'port' => 'integer', 'progress' => 'integer', 'aliases' => 'array', diff --git a/database/migrations/2026_01_01_151401_add_env_variables_to_sites_table.php b/database/migrations/2026_01_01_151401_add_env_variables_to_sites_table.php new file mode 100644 index 000000000..7ce7706ae --- /dev/null +++ b/database/migrations/2026_01_01_151401_add_env_variables_to_sites_table.php @@ -0,0 +1,28 @@ +text('env_variables')->nullable()->after('type_data'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sites', function (Blueprint $table) { + $table->dropColumn('env_variables'); + }); + } +}; diff --git a/public/api-docs/openapi/sites.yaml b/public/api-docs/openapi/sites.yaml index 62ba65508..c4e2582c3 100644 --- a/public/api-docs/openapi/sites.yaml +++ b/public/api-docs/openapi/sites.yaml @@ -708,7 +708,7 @@ paths: /api/projects/{project}/servers/{server}/sites/{site}/env: get: summary: Get environment variables - description: Get site environment variables + description: Get site environment variables. Returns both raw env content and structured variables array. tags: - Sites security: @@ -747,20 +747,40 @@ paths: type: object properties: env: - type: object - additionalProperties: - type: string - description: Environment variables - example: - APP_NAME: 'My App' - APP_ENV: 'production' - APP_DEBUG: 'false' + type: string + description: Raw .env file content + example: "APP_NAME=MyApp\nAPP_ENV=production" + variables: + type: array + description: Parsed environment variables + items: + type: object + properties: + key: + type: string + description: Variable name + example: 'APP_NAME' + value: + type: string + description: Variable value + example: 'MyApp' + is_secret: + type: boolean + description: Whether the variable contains sensitive data (PASSWORD, SECRET, TOKEN, KEY, PRIVATE) + example: false example: data: - env: - APP_NAME: 'My App' - APP_ENV: 'production' - APP_DEBUG: 'false' + env: "APP_NAME=MyApp\nAPP_ENV=production\nDB_PASSWORD=secret123" + variables: + - key: 'APP_NAME' + value: 'MyApp' + is_secret: false + - key: 'APP_ENV' + value: 'production' + is_secret: false + - key: 'DB_PASSWORD' + value: 'secret123' + is_secret: true '401': description: Unauthorized content: @@ -782,7 +802,7 @@ paths: put: summary: Update environment variables - description: Update site environment variables + description: Update site environment variables. You can provide either raw env content or structured variables array. tags: - Sites security: @@ -815,23 +835,44 @@ paths: application/json: schema: type: object - required: - - env properties: env: - type: object - additionalProperties: - type: string - description: Environment variables - example: - APP_NAME: 'My App' - APP_ENV: 'production' - APP_DEBUG: 'false' - example: - env: - APP_NAME: 'My App' - APP_ENV: 'production' - APP_DEBUG: 'false' + type: string + description: Raw .env file content (use this OR variables, not both) + example: "APP_NAME=MyApp\nAPP_ENV=production" + variables: + type: array + description: Structured environment variables (use this OR env, not both) + items: + type: object + required: + - key + properties: + key: + type: string + description: Variable name + example: 'APP_NAME' + value: + type: string + description: Variable value + example: 'MyApp' + path: + type: string + description: Custom path to the .env file + example: '/home/vito/myapp/.env' + examples: + raw: + summary: Using raw env content + value: + env: "APP_NAME=MyApp\nAPP_ENV=production" + structured: + summary: Using structured variables + value: + variables: + - key: 'APP_NAME' + value: 'MyApp' + - key: 'APP_ENV' + value: 'production' responses: '200': description: Environment variables updated successfully diff --git a/resources/js/components/ui/auto-grow-textarea.tsx b/resources/js/components/ui/auto-grow-textarea.tsx new file mode 100644 index 000000000..b75aad6b7 --- /dev/null +++ b/resources/js/components/ui/auto-grow-textarea.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { useInputFocus } from '@/stores/useInputFocus'; + +type AutoGrowTextareaProps = React.ComponentProps<'textarea'>; + +const MAX_HEIGHT = 200; // Maximum height in pixels before scrolling + +const AutoGrowTextarea = React.forwardRef(({ className, value, onChange, ...props }, ref) => { + const setFocused = useInputFocus((state) => state.setFocused); + const textareaRef = React.useRef(null); + + // Combine refs + React.useImperativeHandle(ref, () => textareaRef.current as HTMLTextAreaElement); + + const adjustHeight = React.useCallback(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = 'auto'; + const newHeight = Math.min(textarea.scrollHeight, MAX_HEIGHT); + textarea.style.height = `${newHeight}px`; + textarea.style.overflowY = textarea.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden'; + } + }, []); + + React.useEffect(() => { + adjustHeight(); + }, [value, adjustHeight]); + + const handleChange = (e: React.ChangeEvent) => { + onChange?.(e); + adjustHeight(); + }; + + return ( +