Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
56 changes: 54 additions & 2 deletions app/Actions/Site/UpdateEnv.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<string, mixed> $input
* @return array<int, array{key: string, value: string, is_secret: bool}>
*/
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;
}
}
163 changes: 163 additions & 0 deletions app/Helpers/EnvParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php

namespace App\Helpers;

class EnvParser
{
/**
* Secret key patterns - keys containing these words are considered secrets
*
* @var array<string>
*/
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<int, array{key: string, value: string, is_secret: bool}>
*/
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<int, array{key: string, value: string}> $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<int, array{key: string, value: string, is_secret: bool}> $variables
* @return array<int, array{key: string, value: string, is_secret: bool}>
*/
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<int, array{key: string, value: string, is_secret: bool}> $incoming
* @param array<int, array{key: string, value: string, is_secret: bool}>|null $stored
* @return array<int, array{key: string, value: string, is_secret: bool}>
*/
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);
}
}
12 changes: 11 additions & 1 deletion app/Http/Controllers/API/SiteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
],
]);
}
Expand Down
24 changes: 24 additions & 0 deletions app/Http/Controllers/ApplicationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
]);
}

Expand Down
3 changes: 3 additions & 0 deletions app/Models/Site.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
* @property int $server_id
* @property string $type
* @property array<string, mixed> $type_data
* @property ?array<int, array{key: string, value: string, is_secret: bool}> $env_variables
* @property string $domain
* @property array<int, string> $aliases
* @property string $web_directory
Expand Down Expand Up @@ -72,6 +73,7 @@ class Site extends AbstractModel
'server_id',
'type',
'type_data',
'env_variables',
'domain',
'aliases',
'web_directory',
Expand All @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->text('env_variables')->nullable()->after('type_data');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sites', function (Blueprint $table) {
$table->dropColumn('env_variables');
});
}
};
Loading