mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 06:17:36 +00:00
Merge remote-tracking branch 'origin/next' into feat/api-destinations
This commit is contained in:
commit
4ef884e1ac
112 files changed
+4523
-1814
No files matched your search
@@ -2876,8 +2876,12 @@ class ApplicationsController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $application);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
@@ -3100,12 +3104,18 @@ class ApplicationsController extends Controller
|
||||
], 400);
|
||||
}
|
||||
$bulk_data = collect($bulk_data)->map(function ($item) {
|
||||
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
|
||||
$item = collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
|
||||
|
||||
if ($item->has('key')) {
|
||||
$item->put('key', ValidationPatterns::normalizeEnvironmentVariableKey((string) $item->get('key')));
|
||||
}
|
||||
|
||||
return $item;
|
||||
});
|
||||
$returnedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
@@ -3302,8 +3312,12 @@ class ApplicationsController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $application);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_preview' => 'boolean',
|
||||
'is_literal' => 'boolean',
|
||||
@@ -4265,10 +4279,11 @@ class ApplicationsController extends Controller
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -4292,7 +4307,7 @@ class ApplicationsController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -4323,6 +4338,14 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -4345,12 +4368,50 @@ class ApplicationsController extends Controller
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]);
|
||||
} else {
|
||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
||||
|
||||
validateShellSafePath($mountPath, 'file storage path');
|
||||
|
||||
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
|
||||
@@ -3133,8 +3133,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $database);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -3281,8 +3285,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$updatedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
if (array_key_exists('key', $item)) {
|
||||
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -3399,8 +3407,12 @@ class DatabasesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $database);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -3684,10 +3696,11 @@ class DatabasesController extends Controller
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -3711,7 +3724,7 @@ class DatabasesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -3742,6 +3755,14 @@ class DatabasesController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -3764,12 +3785,50 @@ class DatabasesController extends Controller
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]);
|
||||
} else {
|
||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
||||
|
||||
validateShellSafePath($mountPath, 'file storage path');
|
||||
|
||||
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
|
||||
@@ -1251,8 +1251,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1400,8 +1404,12 @@ class ServicesController extends Controller
|
||||
|
||||
$updatedEnvs = collect();
|
||||
foreach ($bulk_data as $item) {
|
||||
if (array_key_exists('key', $item)) {
|
||||
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($item, [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -1519,8 +1527,12 @@ class ServicesController extends Controller
|
||||
|
||||
$this->authorize('manageEnvironment', $service);
|
||||
|
||||
if ($request->has('key')) {
|
||||
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'key' => 'string|required',
|
||||
'key' => ValidationPatterns::environmentVariableKeyRules(),
|
||||
'value' => 'string|nullable',
|
||||
'is_literal' => 'boolean',
|
||||
'is_multiline' => 'boolean',
|
||||
@@ -2103,10 +2115,11 @@ class ServicesController extends Controller
|
||||
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
|
||||
'content' => 'string|nullable',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'fs_path' => 'string',
|
||||
]);
|
||||
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
|
||||
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
|
||||
if ($validator->fails() || ! empty($extraFields)) {
|
||||
$errors = $validator->errors();
|
||||
@@ -2138,7 +2151,7 @@ class ServicesController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
|
||||
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
|
||||
if (! empty($typeSpecificInvalidFields)) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
@@ -2169,6 +2182,14 @@ class ServicesController extends Controller
|
||||
}
|
||||
|
||||
$isDirectory = $request->boolean('is_directory', false);
|
||||
$isHostFile = $request->boolean('is_host_file', false);
|
||||
|
||||
if ($isDirectory && $isHostFile) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($isDirectory) {
|
||||
if (! $request->fs_path) {
|
||||
@@ -2191,12 +2212,50 @@ class ServicesController extends Controller
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]);
|
||||
} elseif ($isHostFile) {
|
||||
if (! $request->fs_path) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($request->filled('content')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['content' => 'Content is not valid for host file mounts.'],
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
'mount_path' => $mountPath,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]);
|
||||
} else {
|
||||
$mountPath = str($request->mount_path)->trim()->start('/')->value();
|
||||
|
||||
validateShellSafePath($mountPath, 'file storage path');
|
||||
|
||||
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
|
||||
try {
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => ['mount_path' => $e->getMessage()],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$storage = LocalFileVolume::create([
|
||||
'fs_path' => $fsPath,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
@@ -16,24 +17,7 @@ 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',
|
||||
'dmp',
|
||||
];
|
||||
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
|
||||
|
||||
public function upload(Request $request)
|
||||
{
|
||||
@@ -85,10 +69,7 @@ class UploadController extends BaseController
|
||||
|
||||
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
|
||||
{
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$size = $file->getSize();
|
||||
|
||||
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
|
||||
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
|
||||
@unlink($file->getPathname());
|
||||
|
||||
return response()->json([
|
||||
@@ -108,24 +89,7 @@ class UploadController extends BaseController
|
||||
|
||||
private static function hasAllowedExtension(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
|
||||
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
|
||||
|
||||
foreach ($suffixes as $suffix) {
|
||||
if (! str_ends_with($lower, $suffix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stem = substr($lower, 0, -strlen($suffix));
|
||||
if ($stem !== '' && ! str_ends_with($stem, '.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
return DatabaseBackupFileValidator::hasAllowedExtension($name);
|
||||
}
|
||||
|
||||
private static function formatMaxSize(): string
|
||||
|
||||
@@ -261,6 +261,16 @@ class Github extends Controller
|
||||
return response('Nothing to do. No GitHub App found.');
|
||||
}
|
||||
$webhook_secret = data_get($github_app, 'webhook_secret');
|
||||
if (empty($webhook_secret)) {
|
||||
auditLogWebhookFailure('github', 'webhook_secret_missing', [
|
||||
'mode' => 'app',
|
||||
'github_app_id' => $github_app->id,
|
||||
'github_app_name' => $github_app->name,
|
||||
'installation_target_id' => $x_github_hook_installation_target_id,
|
||||
]);
|
||||
|
||||
return response('Invalid signature.');
|
||||
}
|
||||
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
|
||||
if (config('app.env') !== 'local') {
|
||||
if (! hash_equals($x_hub_signature_256, $hmac)) {
|
||||
|
||||
@@ -12,15 +12,14 @@ class CanCreateResources
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
return $next($request);
|
||||
// if (! Gate::allows('createAnyResource')) {
|
||||
// abort(403, 'You do not have permission to create resources.');
|
||||
// }
|
||||
if (! Gate::allows('createAnyResource')) {
|
||||
abort(403, 'You do not have permission to create resources.');
|
||||
}
|
||||
|
||||
// return $next($request);
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
@@ -23,53 +24,61 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CanUpdateResource
|
||||
{
|
||||
/**
|
||||
* @var array<string, list<class-string>>
|
||||
*/
|
||||
private const ROUTE_RESOURCE_MODELS = [
|
||||
'application_uuid' => [Application::class],
|
||||
'database_uuid' => [
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
StandaloneMongodb::class,
|
||||
],
|
||||
'stack_service_uuid' => [ServiceApplication::class, ServiceDatabase::class],
|
||||
'service_uuid' => [Service::class],
|
||||
'server_uuid' => [Server::class],
|
||||
'environment_uuid' => [Environment::class],
|
||||
'project_uuid' => [Project::class],
|
||||
];
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$resource = $this->resourceFromRoute($request);
|
||||
|
||||
if (! $resource) {
|
||||
abort(404, 'Resource not found.');
|
||||
}
|
||||
|
||||
if (! Gate::allows('update', $resource)) {
|
||||
abort(403, 'You do not have permission to update this resource.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Get resource from route parameters
|
||||
// $resource = null;
|
||||
// if ($request->route('application_uuid')) {
|
||||
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
|
||||
// } elseif ($request->route('service_uuid')) {
|
||||
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
|
||||
// } elseif ($request->route('stack_service_uuid')) {
|
||||
// // Handle ServiceApplication or ServiceDatabase
|
||||
// $stack_service_uuid = $request->route('stack_service_uuid');
|
||||
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
|
||||
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
|
||||
// } elseif ($request->route('database_uuid')) {
|
||||
// // Try different database types
|
||||
// $database_uuid = $request->route('database_uuid');
|
||||
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
|
||||
// StandaloneMongodb::where('uuid', $database_uuid)->first();
|
||||
// } elseif ($request->route('server_uuid')) {
|
||||
// // For server routes, check if user can manage servers
|
||||
// if (! auth()->user()->isAdmin()) {
|
||||
// abort(403, 'You do not have permission to access this resource.');
|
||||
// }
|
||||
private function resourceFromRoute(Request $request): ?object
|
||||
{
|
||||
foreach (self::ROUTE_RESOURCE_MODELS as $routeParameter => $models) {
|
||||
$uuid = $request->route($routeParameter);
|
||||
|
||||
// return $next($request);
|
||||
// } elseif ($request->route('environment_uuid')) {
|
||||
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
|
||||
// } elseif ($request->route('project_uuid')) {
|
||||
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
|
||||
// }
|
||||
if (! $uuid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// if (! $resource) {
|
||||
// abort(404, 'Resource not found.');
|
||||
// }
|
||||
foreach ($models as $model) {
|
||||
$resource = $model::where('uuid', $uuid)->first();
|
||||
|
||||
// if (! Gate::allows('update', $resource)) {
|
||||
// abort(403, 'You do not have permission to update this resource.');
|
||||
// }
|
||||
if ($resource) {
|
||||
return $resource;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return $next($request);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1031,7 +1031,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
);
|
||||
}
|
||||
foreach ($this->application->fileStorages as $fileStorage) {
|
||||
if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
||||
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
|
||||
$fileStorage->saveStorageOnServer();
|
||||
}
|
||||
}
|
||||
@@ -1833,8 +1833,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
]
|
||||
);
|
||||
}
|
||||
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
|
||||
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
|
||||
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
|
||||
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
|
||||
// This ensures the file exists when referenced in build commands
|
||||
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
|
||||
|
||||
@@ -2306,6 +2306,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
@@ -2365,12 +2367,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
if ($this->pull_request_id !== 0) {
|
||||
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
|
||||
}
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
$importCommands,
|
||||
'hidden' => true,
|
||||
]
|
||||
);
|
||||
$this->execute_remote_command(...$this->gitCommandDefinitions($importCommands));
|
||||
$this->create_workdir();
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
@@ -2400,6 +2397,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $commands;
|
||||
}
|
||||
|
||||
private function gitCommandDefinitions(Collection|array|string $commands): array
|
||||
{
|
||||
if (is_string($commands)) {
|
||||
return [
|
||||
[
|
||||
$commands,
|
||||
'hidden' => true,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return collect($commands)
|
||||
->map(function ($command): array {
|
||||
if (is_string($command)) {
|
||||
return [
|
||||
$command,
|
||||
'hidden' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (is_array($command)) {
|
||||
return $command + ['hidden' => true];
|
||||
}
|
||||
|
||||
return [
|
||||
'command' => $command,
|
||||
'hidden' => true,
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function cleanup_git()
|
||||
{
|
||||
$this->execute_remote_command(
|
||||
@@ -2667,12 +2697,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
|
||||
}
|
||||
|
||||
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
|
||||
// Build-time variables reach the build through the sourced build-time .env file
|
||||
// (written by save_buildtime_environment_variables), which interpolates shell-style
|
||||
// references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env`
|
||||
// would forward the literal `$COOLIFY_URL` because each value is single-quoted and
|
||||
// `env` does not interpolate its own assignments. Only buildpack control variables
|
||||
// (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never
|
||||
// need interpolation — are still passed inline.
|
||||
$controlVariables = $variables->filter(
|
||||
fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES)
|
||||
);
|
||||
|
||||
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
|
||||
$secretFlags = $this->railpack_build_secret_flags($variables);
|
||||
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
|
||||
|
||||
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
|
||||
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
|
||||
$buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack"
|
||||
." {$this->addHosts} --network host"
|
||||
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
|
||||
." {$cacheArgs}"
|
||||
@@ -2682,6 +2722,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
.' --load'
|
||||
." -t {$imageName}"
|
||||
." {$this->workdir}";
|
||||
|
||||
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
|
||||
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
|
||||
}
|
||||
|
||||
private function decode_railpack_config(string $config, string $source): array
|
||||
|
||||
@@ -714,9 +714,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$escapedEndpoint = escapeshellarg($endpoint);
|
||||
$escapedKey = escapeshellarg($key);
|
||||
$escapedSecret = escapeshellarg($secret);
|
||||
$escapedBackupLocation = escapeshellarg($this->backup_location);
|
||||
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
|
||||
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
|
||||
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||
|
||||
$this->s3_uploaded = true;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Notifications\Dto\DiscordMessage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -41,6 +44,20 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Http::post($this->webhookUrl, $this->message->toPayload());
|
||||
$validator = Validator::make(
|
||||
['webhook_url' => $this->webhookUrl],
|
||||
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
|
||||
'url' => $this->webhookUrl,
|
||||
'errors' => $validator->errors()->all(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Notifications\Dto\SlackMessage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -34,6 +37,20 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$validator = Validator::make(
|
||||
['webhook_url' => $this->webhookUrl],
|
||||
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
|
||||
'url' => $this->webhookUrl,
|
||||
'errors' => $validator->errors()->all(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isSlackWebhook()) {
|
||||
$this->sendToSlack();
|
||||
|
||||
@@ -64,7 +81,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function sendToSlack(): void
|
||||
{
|
||||
Http::post($this->webhookUrl, [
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
||||
'text' => $this->message->title,
|
||||
'blocks' => [
|
||||
[
|
||||
@@ -106,7 +123,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
$username = config('app.name');
|
||||
|
||||
Http::post($this->webhookUrl, [
|
||||
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
|
||||
'username' => $username,
|
||||
'attachments' => [
|
||||
[
|
||||
|
||||
@@ -64,7 +64,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
$response = Http::post($this->webhookUrl, $this->payload);
|
||||
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
|
||||
|
||||
if (isDev()) {
|
||||
ray('Webhook response', [
|
||||
|
||||
@@ -260,7 +260,10 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$comment = data_get($data, 'cancellation_details.comment');
|
||||
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
|
||||
if (str($lookup_key)->contains('dynamic')) {
|
||||
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
|
||||
$quantity = max(
|
||||
UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
|
||||
min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
|
||||
);
|
||||
$team = data_get($subscription, 'team');
|
||||
if ($team) {
|
||||
$team->update([
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Destination;
|
||||
|
||||
use App\Models\StandaloneDocker;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
@@ -35,7 +36,7 @@ class Show extends Component
|
||||
|
||||
$this->destination = $destination;
|
||||
$this->syncData();
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException) {
|
||||
} catch (AuthorizationException) {
|
||||
abort(403);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
||||
@@ -170,11 +170,15 @@ class Email extends Component
|
||||
$this->smtpPort = $this->settings->smtp_port;
|
||||
$this->smtpEncryption = $this->settings->smtp_encryption;
|
||||
$this->smtpUsername = $this->settings->smtp_username;
|
||||
$this->smtpPassword = $this->settings->smtp_password;
|
||||
$this->smtpPassword = auth()->user()->can('update', $this->settings)
|
||||
? $this->settings->smtp_password
|
||||
: null;
|
||||
$this->smtpTimeout = $this->settings->smtp_timeout;
|
||||
|
||||
$this->resendEnabled = $this->settings->resend_enabled;
|
||||
$this->resendApiKey = $this->settings->resend_api_key;
|
||||
$this->resendApiKey = auth()->user()->can('update', $this->settings)
|
||||
? $this->settings->resend_api_key
|
||||
: null;
|
||||
|
||||
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
|
||||
|
||||
@@ -242,6 +246,8 @@ class Email extends Component
|
||||
|
||||
public function submitSmtp()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
@@ -289,6 +295,8 @@ class Email extends Component
|
||||
|
||||
public function submitResend()
|
||||
{
|
||||
$this->authorize('update', $this->settings);
|
||||
|
||||
try {
|
||||
$this->resetErrorBag();
|
||||
$this->validate([
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Jobs\DatabaseBackupJob;
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
@@ -17,7 +20,7 @@ class BackupEdit extends Component
|
||||
public ScheduledDatabaseBackup $backup;
|
||||
|
||||
#[Locked]
|
||||
public $s3s;
|
||||
public $availableS3Storages;
|
||||
|
||||
#[Locked]
|
||||
public $parameters;
|
||||
@@ -68,7 +71,7 @@ class BackupEdit extends Component
|
||||
public bool $disableLocalBackup = false;
|
||||
|
||||
#[Validate(['nullable', 'integer'])]
|
||||
public ?int $s3StorageId = 1;
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
#[Validate(['nullable', 'string'])]
|
||||
public ?string $databasesToBackup = null;
|
||||
@@ -128,7 +131,7 @@ class BackupEdit extends Component
|
||||
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
|
||||
$this->saveS3 = $this->backup->save_s3;
|
||||
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id;
|
||||
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
|
||||
$this->databasesToBackup = $this->backup->databases_to_backup;
|
||||
$this->dumpAll = $this->backup->dump_all;
|
||||
$this->timeout = $this->backup->timeout;
|
||||
@@ -195,7 +198,7 @@ class BackupEdit extends Component
|
||||
try {
|
||||
$this->authorize('manageBackups', $this->backup->database);
|
||||
|
||||
\App\Jobs\DatabaseBackupJob::dispatch($this->backup);
|
||||
DatabaseBackupJob::dispatch($this->backup);
|
||||
$this->dispatch('success', 'Backup queued. It will be available in a few minutes.');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
@@ -214,6 +217,11 @@ class BackupEdit extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedS3StorageId(): void
|
||||
{
|
||||
$this->instantSave();
|
||||
}
|
||||
|
||||
private function customValidate()
|
||||
{
|
||||
if (! is_numeric($this->backup->s3_storage_id)) {
|
||||
@@ -221,10 +229,14 @@ class BackupEdit extends Component
|
||||
}
|
||||
|
||||
// S3 backup cannot be enabled without a valid S3 storage owned by the team
|
||||
$availableS3Ids = collect($this->s3s)->pluck('id');
|
||||
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
$availableS3Ids = $this->availableS3StorageIds();
|
||||
if ($availableS3Ids->isEmpty()) {
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = null;
|
||||
if ($this->backup->save_s3) {
|
||||
$this->backup->save_s3 = $this->saveS3 = false;
|
||||
}
|
||||
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
|
||||
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
|
||||
}
|
||||
|
||||
// Validate that disable_local_backup can only be true when S3 backup is enabled
|
||||
@@ -239,6 +251,28 @@ class BackupEdit extends Component
|
||||
$this->validate();
|
||||
}
|
||||
|
||||
private function availableS3StorageIds(): Collection
|
||||
{
|
||||
$storages = collect($this->availableS3Storages);
|
||||
$storageIds = $storages->pluck('id')->filter()->all();
|
||||
|
||||
if (empty($storageIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
|
||||
|
||||
if (empty($teamIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return S3Storage::query()
|
||||
->whereKey($storageIds)
|
||||
->whereIn('team_id', $teamIds)
|
||||
->where('is_usable', true)
|
||||
->pluck('id');
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -97,7 +98,7 @@ class BackupExecutions extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === \App\Models\ServiceDatabase::class
|
||||
$server = $execution->scheduledDatabaseBackup->database->getMorphClass() === ServiceDatabase::class
|
||||
? $execution->scheduledDatabaseBackup->database->service->destination->server
|
||||
: $execution->scheduledDatabaseBackup->database->destination->server;
|
||||
|
||||
@@ -204,7 +205,7 @@ class BackupExecutions extends Component
|
||||
if ($this->database) {
|
||||
$server = null;
|
||||
|
||||
if ($this->database instanceof \App\Models\ServiceDatabase) {
|
||||
if ($this->database instanceof ServiceDatabase) {
|
||||
$server = $this->database->service->destination->server;
|
||||
} elseif ($this->database->destination && $this->database->destination->server) {
|
||||
$server = $this->database->destination->server;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\DatabaseBackupFileValidator;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -27,11 +28,10 @@ class ImportForm extends Component
|
||||
|
||||
/**
|
||||
* Validate that a string is safe for use as an S3 bucket name.
|
||||
* Allows alphanumerics, dots, dashes, and underscores.
|
||||
*/
|
||||
private function validateBucketName(string $bucket): bool
|
||||
{
|
||||
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||
return ValidationPatterns::isValidS3BucketName($bucket);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,10 +451,20 @@ EOD;
|
||||
// Check if an uploaded file exists first (takes priority over custom location)
|
||||
if (Storage::exists($backupFileName)) {
|
||||
$path = Storage::path($backupFileName);
|
||||
|
||||
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
|
||||
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
|
||||
Storage::delete($backupFileName);
|
||||
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} elseif (filled($this->customLocation)) {
|
||||
// Validate the custom location to prevent command injection
|
||||
if (! $this->validateServerPath($this->customLocation)) {
|
||||
@@ -465,6 +475,7 @@ EOD;
|
||||
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
|
||||
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
|
||||
} else {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
@@ -570,7 +581,7 @@ EOD;
|
||||
|
||||
// Validate bucket name early
|
||||
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -651,7 +662,7 @@ EOD;
|
||||
|
||||
// Validate bucket name to prevent command injection
|
||||
if (! $this->validateBucketName($bucket)) {
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -721,6 +732,7 @@ EOD;
|
||||
// 6. Copy from helper to server, then immediately to database container
|
||||
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
|
||||
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
|
||||
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
|
||||
|
||||
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||
@@ -765,6 +777,69 @@ EOD;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
|
||||
{
|
||||
$script = $this->buildPostgresRestoreScanScript($tmpPath);
|
||||
|
||||
if ($script === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
|
||||
* backup contains directives leading to OS command execution.
|
||||
*
|
||||
* Hardened against bypasses:
|
||||
* - decompresses gzip backups before scanning,
|
||||
* - strips `--` line comments and flattens newlines so multi-line and
|
||||
* comment-separated payloads (e.g. `FROM/**/PROGRAM`) are caught,
|
||||
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
|
||||
*/
|
||||
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
|
||||
{
|
||||
if (! $this->isPostgresqlRestore()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
|
||||
// Token separator PostgreSQL treats as whitespace: real whitespace or a
|
||||
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
|
||||
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
|
||||
|
||||
$pattern = implode('|', [
|
||||
"copy{$sep}+[^;]*(from|to){$sep}+program",
|
||||
'(^|[[:space:]])\\\\!',
|
||||
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
|
||||
]);
|
||||
$escapedPattern = escapeshellarg($pattern);
|
||||
|
||||
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
|
||||
}
|
||||
|
||||
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
|
||||
{
|
||||
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
|
||||
|
||||
if ($command !== null) {
|
||||
$commands[] = $command;
|
||||
}
|
||||
}
|
||||
|
||||
private function isPostgresqlRestore(): bool
|
||||
{
|
||||
$morphClass = $this->resource->getMorphClass();
|
||||
|
||||
if ($morphClass === ServiceDatabase::class) {
|
||||
return str_contains($this->resource->databaseType(), 'postgres');
|
||||
}
|
||||
|
||||
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
|
||||
}
|
||||
|
||||
public function buildRestoreCommand(string $tmpPath): string
|
||||
{
|
||||
$escapedTmpPath = escapeshellarg($tmpPath);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -34,7 +35,7 @@ class ScheduledBackups extends Component
|
||||
$this->setSelectedBackup($this->selectedBackupId, true);
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
if ($this->database->getMorphClass() === \App\Models\ServiceDatabase::class) {
|
||||
if ($this->database->getMorphClass() === ServiceDatabase::class) {
|
||||
$this->type = 'service-database';
|
||||
} else {
|
||||
$this->type = 'database';
|
||||
|
||||
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class DockerCompose extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $dockerComposeRaw = '';
|
||||
|
||||
public string $envFile = '';
|
||||
@@ -30,6 +33,8 @@ class DockerCompose extends Component
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Service::class);
|
||||
|
||||
$this->validate([
|
||||
'dockerComposeRaw' => 'required',
|
||||
]);
|
||||
|
||||
@@ -6,10 +6,13 @@ use App\Models\Application;
|
||||
use App\Models\Project;
|
||||
use App\Services\DockerImageParser;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class DockerImage extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $imageName = '';
|
||||
|
||||
public string $imageTag = '';
|
||||
@@ -80,6 +83,8 @@ class DockerImage extends Component
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
$this->validate([
|
||||
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
|
||||
'imageTag' => ValidationPatterns::dockerImageTagRules(),
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
namespace App\Livewire\Project\New;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class EmptyProject extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function createEmptyProject()
|
||||
{
|
||||
$this->authorize('create', Project::class);
|
||||
|
||||
$project = Project::create([
|
||||
'name' => generate_random_name(),
|
||||
'team_id' => currentTeam()->id,
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -14,6 +15,8 @@ use Livewire\Component;
|
||||
|
||||
class GithubPrivateRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $current_step = 'github_apps';
|
||||
|
||||
public $github_apps;
|
||||
@@ -169,6 +172,8 @@ class GithubPrivateRepository extends Component
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
// Validate git repository parts and branch
|
||||
$validator = validator([
|
||||
'selected_repository_owner' => $this->selected_repository_owner,
|
||||
|
||||
@@ -10,12 +10,15 @@ use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class GithubPrivateRepositoryDeployKey extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $current_step = 'private_keys';
|
||||
|
||||
public $parameters;
|
||||
@@ -128,6 +131,8 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
$this->validate();
|
||||
try {
|
||||
$destination_uuid = $this->query['destination'] ?? null;
|
||||
|
||||
@@ -6,16 +6,18 @@ use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\Project;
|
||||
use App\Models\Service;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
||||
class PublicGitRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $repository_url;
|
||||
|
||||
public int $port = 3000;
|
||||
@@ -260,6 +262,8 @@ class PublicGitRepository extends Component
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
$this->validate();
|
||||
|
||||
// Additional validation for git repository and branch
|
||||
@@ -295,33 +299,6 @@ class PublicGitRepository extends Component
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
|
||||
|
||||
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
|
||||
$server = $destination->server;
|
||||
$new_service = [
|
||||
'name' => 'service'.str()->random(10),
|
||||
'docker_compose_raw' => 'coolify',
|
||||
'environment_id' => $environment->id,
|
||||
'server_id' => $server->id,
|
||||
];
|
||||
if ($this->git_source === 'other') {
|
||||
$new_service['git_repository'] = $this->git_repository;
|
||||
$new_service['git_branch'] = $this->git_branch;
|
||||
} else {
|
||||
$new_service['git_repository'] = $this->git_repository;
|
||||
$new_service['git_branch'] = $this->git_branch;
|
||||
$new_service['source_id'] = $this->git_source->id;
|
||||
$new_service['source_type'] = $this->git_source->getMorphClass();
|
||||
}
|
||||
$service = Service::create($new_service);
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
'project_uuid' => $project->uuid,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->git_source === 'other') {
|
||||
$application_init = [
|
||||
'name' => generate_random_name(),
|
||||
|
||||
@@ -5,10 +5,13 @@ namespace App\Livewire\Project\New;
|
||||
use App\Models\Application;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class SimpleDockerfile extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public string $dockerfile = '';
|
||||
|
||||
public array $parameters;
|
||||
@@ -29,6 +32,8 @@ CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
|
||||
$this->validate([
|
||||
'dockerfile' => 'required',
|
||||
]);
|
||||
|
||||
@@ -4,16 +4,20 @@ namespace App\Livewire\Project\Resource;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\Service;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
class Create extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $type;
|
||||
|
||||
public $project;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->authorize('createAnyResource');
|
||||
|
||||
$type = str(request()->query('type'));
|
||||
$destination_uuid = request()->query('destination');
|
||||
|
||||
@@ -94,6 +94,10 @@ class FileStorage extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->fileStorage->is_host_file) {
|
||||
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
|
||||
}
|
||||
|
||||
$this->fileStorage->deleteStorageOnServer();
|
||||
$this->fileStorage->is_directory = true;
|
||||
$this->fileStorage->content = null;
|
||||
@@ -112,6 +116,10 @@ class FileStorage extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->fileStorage->is_host_file) {
|
||||
throw new \Exception('Host file mounts are bind-only and cannot be loaded from the server.');
|
||||
}
|
||||
|
||||
$this->fileStorage->loadStorageOnServer();
|
||||
$this->syncData();
|
||||
$this->dispatch('success', 'File storage loaded from server.');
|
||||
@@ -127,6 +135,10 @@ class FileStorage extends Component
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->fileStorage->is_host_file) {
|
||||
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
|
||||
}
|
||||
|
||||
$this->fileStorage->deleteStorageOnServer();
|
||||
$this->fileStorage->is_directory = false;
|
||||
$this->fileStorage->content = null;
|
||||
@@ -154,8 +166,10 @@ class FileStorage extends Component
|
||||
$message = 'File deleted.';
|
||||
if ($this->fileStorage->is_directory) {
|
||||
$message = 'Directory deleted.';
|
||||
} elseif ($this->fileStorage->is_host_file) {
|
||||
$message = 'Host file mount removed.';
|
||||
}
|
||||
if ($this->permanently_delete) {
|
||||
if ($this->permanently_delete && ! $this->fileStorage->is_host_file) {
|
||||
$message = 'Directory deleted from the server.';
|
||||
$this->fileStorage->deleteStorageOnServer();
|
||||
}
|
||||
@@ -174,6 +188,12 @@ class FileStorage extends Component
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if ($this->fileStorage->is_host_file) {
|
||||
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||
|
||||
@@ -205,6 +225,12 @@ class FileStorage extends Component
|
||||
public function instantSave(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
if ($this->fileStorage->is_host_file) {
|
||||
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->fileStorage->is_too_large) {
|
||||
$this->dispatch('error', 'File on server is too large to edit from the UI.');
|
||||
|
||||
@@ -223,6 +249,9 @@ class FileStorage extends Component
|
||||
'fileDeletionCheckboxes' => [
|
||||
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
|
||||
],
|
||||
'hostFileDeletionCheckboxes' => [
|
||||
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ class Storage extends Component
|
||||
|
||||
public ?string $file_storage_content = null;
|
||||
|
||||
public string $host_file_storage_source = '';
|
||||
|
||||
public string $host_file_storage_destination = '';
|
||||
|
||||
public string $file_storage_directory_source = '';
|
||||
|
||||
public string $file_storage_directory_destination = '';
|
||||
@@ -146,19 +150,9 @@ class Storage extends Component
|
||||
'file_storage_content' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$this->file_storage_path = trim($this->file_storage_path);
|
||||
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
|
||||
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
|
||||
|
||||
// Validate path to prevent command injection
|
||||
validateShellSafePath($this->file_storage_path, 'file storage path');
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
|
||||
} else {
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => $fs_path,
|
||||
@@ -178,6 +172,38 @@ class Storage extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function submitHostFileStorage()
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$this->validate([
|
||||
'host_file_storage_source' => 'required|string',
|
||||
'host_file_storage_destination' => 'required|string',
|
||||
]);
|
||||
|
||||
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
|
||||
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
|
||||
|
||||
LocalFileVolume::create([
|
||||
'fs_path' => $this->host_file_storage_source,
|
||||
'mount_path' => $this->host_file_storage_destination,
|
||||
'content' => null,
|
||||
'is_directory' => false,
|
||||
'is_host_file' => true,
|
||||
'resource_id' => $this->resource->id,
|
||||
'resource_type' => get_class($this->resource),
|
||||
]);
|
||||
|
||||
$this->dispatch('success', 'Host file mount added successfully');
|
||||
$this->dispatch('closeStorageModal', 'host-file');
|
||||
$this->clearForm();
|
||||
$this->refreshStorages();
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function submitFileStorageDirectory()
|
||||
{
|
||||
try {
|
||||
@@ -222,6 +248,8 @@ class Storage extends Component
|
||||
$this->file_storage_path = '';
|
||||
$this->file_storage_content = null;
|
||||
$this->file_storage_directory_destination = '';
|
||||
$this->host_file_storage_source = '';
|
||||
$this->host_file_storage_destination = '';
|
||||
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
|
||||
@@ -230,6 +258,34 @@ class Storage extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function fileStorageHostPath(): string
|
||||
{
|
||||
if (method_exists($this->resource, 'workdir')) {
|
||||
return $this->resource->workdir();
|
||||
}
|
||||
|
||||
if ($this->resource->getMorphClass() === Application::class) {
|
||||
return application_configuration_dir().'/'.$this->resource->uuid;
|
||||
}
|
||||
|
||||
if (str($this->resource->getMorphClass())->contains('Standalone')) {
|
||||
return database_configuration_dir().'/'.$this->resource->uuid;
|
||||
}
|
||||
|
||||
throw new \Exception('No valid resource type for file mount storage type!');
|
||||
}
|
||||
|
||||
public function fileStoragePreviewPath(): string
|
||||
{
|
||||
$path = str($this->file_storage_path)->trim();
|
||||
|
||||
if ($path->isEmpty()) {
|
||||
return $this->fileStorageHostPath().'/';
|
||||
}
|
||||
|
||||
return $this->fileStorageHostPath().$path->start('/')->value();
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.service.storage');
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Uri;
|
||||
@@ -37,7 +38,7 @@ class Create extends Component
|
||||
'region' => 'required|max:255',
|
||||
'key' => 'required|max:255',
|
||||
'secret' => 'required|max:255',
|
||||
'bucket' => 'required|max:255',
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
|
||||
];
|
||||
}
|
||||
@@ -54,7 +55,6 @@ class Create extends Component
|
||||
'secret.required' => 'The Secret Key field is required.',
|
||||
'secret.max' => 'The Secret Key may not be greater than 255 characters.',
|
||||
'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.max' => 'The Endpoint may not be greater than 255 characters.',
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -44,7 +45,7 @@ class Form extends Component
|
||||
'region' => 'required|max:255',
|
||||
'key' => 'required|max:255',
|
||||
'secret' => 'required|max:255',
|
||||
'bucket' => 'required|max:255',
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
|
||||
];
|
||||
}
|
||||
@@ -61,7 +62,6 @@ class Form extends Component
|
||||
'secret.required' => 'The Secret Key field is required.',
|
||||
'secret.max' => 'The Secret Key may not be greater than 255 characters.',
|
||||
'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.max' => 'The Endpoint may not be greater than 255 characters.',
|
||||
]
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -25,7 +26,7 @@ class Show extends Component
|
||||
}
|
||||
try {
|
||||
$this->authorize('view', $this->storage);
|
||||
} catch (\Illuminate\Auth\Access\AuthorizationException) {
|
||||
} catch (AuthorizationException) {
|
||||
return $this->redirectRoute('storage.index', navigate: true);
|
||||
}
|
||||
$this->currentRoute = request()->route()->getName();
|
||||
|
||||
+95
-105
@@ -1338,7 +1338,7 @@ class Application extends BaseModel
|
||||
{
|
||||
try {
|
||||
['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false);
|
||||
instant_remote_process([$lsRemoteCommand], $this->destination->server, true);
|
||||
instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true);
|
||||
|
||||
return [
|
||||
'is_accessible' => true,
|
||||
@@ -1390,13 +1390,13 @@ class Application extends BaseModel
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1413,29 +1413,16 @@ class Application extends BaseModel
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1447,13 +1434,13 @@ class Application extends BaseModel
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1472,29 +1459,16 @@ class Application extends BaseModel
|
||||
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
|
||||
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1506,19 +1480,60 @@ class Application extends BaseModel
|
||||
$base_command = "{$base_command} {$escapedCustomRepository}";
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $base_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
|
||||
} else {
|
||||
$commands->push($base_command);
|
||||
$commands->push($this->gitCommand($base_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function gitCommand(string $command): array
|
||||
{
|
||||
return [
|
||||
'command' => $command,
|
||||
'hidden' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function gitCommandsAsShellCommand(Collection|array|string $commands): string
|
||||
{
|
||||
if (is_string($commands)) {
|
||||
return $commands;
|
||||
}
|
||||
|
||||
return collect($commands)
|
||||
->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command)
|
||||
->implode(' && ');
|
||||
}
|
||||
|
||||
private function gitSshKeySetupCommands(string $deploymentUuid, string $privateKey, bool $execInDocker): Collection
|
||||
{
|
||||
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deploymentUuid}";
|
||||
$commands = collect([]);
|
||||
|
||||
if (! $execInDocker) {
|
||||
$commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT"));
|
||||
}
|
||||
|
||||
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh'));
|
||||
$commands->push([
|
||||
'command' => $execInDocker
|
||||
? executeInDocker($deploymentUuid, "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null")
|
||||
: "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
'hidden' => true,
|
||||
'skip_command_log' => true,
|
||||
]);
|
||||
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, "chmod 600 {$customSshKeyLocation}") : "chmod 600 {$customSshKeyLocation}"));
|
||||
|
||||
return $commands;
|
||||
}
|
||||
|
||||
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
|
||||
{
|
||||
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
|
||||
@@ -1590,9 +1605,9 @@ class Application extends BaseModel
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
} else {
|
||||
$github_access_token = generateGithubInstallationToken($this->source);
|
||||
@@ -1619,9 +1634,9 @@ class Application extends BaseModel
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
}
|
||||
if ($pull_request_id !== 0) {
|
||||
@@ -1631,14 +1646,14 @@ class Application extends BaseModel
|
||||
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
|
||||
$escapedPrBranch = escapeshellarg($branch);
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")));
|
||||
} else {
|
||||
$commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command");
|
||||
$commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1661,39 +1676,26 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
|
||||
if ($pull_request_id !== 0) {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1710,13 +1712,13 @@ class Application extends BaseModel
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1738,55 +1740,42 @@ class Application extends BaseModel
|
||||
} else {
|
||||
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
|
||||
}
|
||||
if ($exec_in_docker) {
|
||||
$commands = collect([
|
||||
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
|
||||
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
|
||||
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
|
||||
]);
|
||||
} else {
|
||||
$commands = collect([
|
||||
"trap 'rm -f {$customSshKeyLocation}' EXIT",
|
||||
'mkdir -p /root/.ssh',
|
||||
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
|
||||
"chmod 600 {$customSshKeyLocation}",
|
||||
]);
|
||||
}
|
||||
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
|
||||
if ($pull_request_id !== 0) {
|
||||
if ($git_type === 'gitlab') {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
|
||||
}
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $commands,
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1807,37 +1796,37 @@ class Application extends BaseModel
|
||||
if ($git_type === 'gitlab') {
|
||||
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'github' || $git_type === 'gitea') {
|
||||
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
|
||||
} elseif ($git_type === 'bitbucket') {
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
|
||||
} else {
|
||||
$commands->push("echo 'Checking out $branch'");
|
||||
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
|
||||
}
|
||||
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if ($exec_in_docker) {
|
||||
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
|
||||
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
|
||||
} else {
|
||||
$commands->push($git_clone_command);
|
||||
$commands->push($this->gitCommand($git_clone_command));
|
||||
}
|
||||
|
||||
return [
|
||||
'commands' => $commands->implode(' && '),
|
||||
'commands' => $this->gitCommandsAsShellCommand($commands),
|
||||
'branch' => $branch,
|
||||
'fullRepoUrl' => $fullRepoUrl,
|
||||
];
|
||||
@@ -1926,6 +1915,7 @@ class Application extends BaseModel
|
||||
}
|
||||
$uuid = new_public_id();
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
|
||||
$cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand);
|
||||
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$composeFile = $this->docker_compose_location;
|
||||
|
||||
@@ -21,6 +21,7 @@ class LocalFileVolume extends BaseModel
|
||||
// 'mount_path' => 'encrypted',
|
||||
'content' => 'encrypted',
|
||||
'is_directory' => 'boolean',
|
||||
'is_host_file' => 'boolean',
|
||||
'is_preview_suffix_enabled' => 'boolean',
|
||||
];
|
||||
|
||||
@@ -33,6 +34,7 @@ class LocalFileVolume extends BaseModel
|
||||
'resource_type',
|
||||
'resource_id',
|
||||
'is_directory',
|
||||
'is_host_file',
|
||||
'chown',
|
||||
'chmod',
|
||||
'is_based_on_git',
|
||||
@@ -44,6 +46,10 @@ class LocalFileVolume extends BaseModel
|
||||
protected static function booted()
|
||||
{
|
||||
static::created(function (LocalFileVolume $fileVolume) {
|
||||
if ($fileVolume->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fileVolume->load(['service']);
|
||||
dispatch(new ServerStorageSaveJob($fileVolume));
|
||||
});
|
||||
@@ -70,6 +76,10 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
public function loadStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -124,6 +134,10 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
public function deleteStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -161,6 +175,10 @@ class LocalFileVolume extends BaseModel
|
||||
|
||||
public function saveStorageOnServer()
|
||||
{
|
||||
if ($this->is_host_file) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->load(['service']);
|
||||
$isService = data_get($this->resource, 'service');
|
||||
if ($isService) {
|
||||
@@ -171,26 +189,26 @@ class LocalFileVolume extends BaseModel
|
||||
$server = $this->resource->destination->server;
|
||||
}
|
||||
$commands = collect([]);
|
||||
|
||||
// Validate fs_path early before any shell interpolation
|
||||
validateShellSafePath($this->fs_path, 'storage path');
|
||||
$escapedFsPath = escapeshellarg($this->fs_path);
|
||||
$escapedWorkdir = escapeshellarg($workdir);
|
||||
|
||||
if ($this->is_directory) {
|
||||
// Validate fs_path early before any shell interpolation
|
||||
validateShellSafePath($this->fs_path, 'storage path');
|
||||
$escapedFsPath = escapeshellarg($this->fs_path);
|
||||
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
|
||||
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
||||
$commands->push("cd {$escapedWorkdir}");
|
||||
}
|
||||
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
|
||||
$parent_dir = str($this->fs_path)->beforeLast('/');
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
$content = data_get($this, 'content');
|
||||
$pathForParentDirectory = str($this->fs_path);
|
||||
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
|
||||
$parent_dir = $pathForParentDirectory->beforeLast('/');
|
||||
if ($parent_dir != '') {
|
||||
$escapedParentDir = escapeshellarg($parent_dir);
|
||||
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
||||
}
|
||||
}
|
||||
$path = data_get_str($this, 'fs_path');
|
||||
$content = data_get($this, 'content');
|
||||
if ($path->startsWith('.')) {
|
||||
$path = $path->after('.');
|
||||
$path = $workdir.$path;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use App\Rules\ValidS3BucketName;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -147,12 +148,22 @@ class S3Storage extends BaseModel
|
||||
{
|
||||
try {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $this['endpoint']],
|
||||
['endpoint' => ['required', new SafeWebhookUrl]],
|
||||
[
|
||||
'endpoint' => $this['endpoint'],
|
||||
'bucket' => $this['bucket'],
|
||||
],
|
||||
[
|
||||
'endpoint' => ['required', new SafeWebhookUrl],
|
||||
'bucket' => ['required', new ValidS3BucketName],
|
||||
],
|
||||
);
|
||||
if ($validator->fails()) {
|
||||
$validator->fails();
|
||||
if ($validator->errors()->has('endpoint')) {
|
||||
throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
|
||||
}
|
||||
if ($validator->errors()->has('bucket')) {
|
||||
throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
|
||||
}
|
||||
|
||||
$disk = Storage::build([
|
||||
'driver' => 's3',
|
||||
@@ -165,6 +176,7 @@ class S3Storage extends BaseModel
|
||||
'http' => [
|
||||
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
|
||||
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
|
||||
'allow_redirects' => false,
|
||||
],
|
||||
]);
|
||||
// Test the connection by listing files with ListObjectsV2 (S3)
|
||||
|
||||
@@ -69,6 +69,6 @@ class ServiceDatabasePolicy
|
||||
*/
|
||||
public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool
|
||||
{
|
||||
return Gate::allows('uploadBackup', $serviceDatabase->service);
|
||||
return $user->can('uploadBackup', $serviceDatabase->service);
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,11 @@ class TeamPolicy
|
||||
*/
|
||||
public function update(User $user, Team $team): bool
|
||||
{
|
||||
// Only admins and owners can update team settings
|
||||
if (! $user->teams->contains('id', $team->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,12 +49,11 @@ class TeamPolicy
|
||||
*/
|
||||
public function delete(User $user, Team $team): bool
|
||||
{
|
||||
// Only admins and owners can delete teams
|
||||
if (! $user->teams->contains('id', $team->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,12 +61,11 @@ class TeamPolicy
|
||||
*/
|
||||
public function manageMembers(User $user, Team $team): bool
|
||||
{
|
||||
// Only admins and owners can manage team members
|
||||
if (! $user->teams->contains('id', $team->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,12 +73,11 @@ class TeamPolicy
|
||||
*/
|
||||
public function viewAdmin(User $user, Team $team): bool
|
||||
{
|
||||
// Only admins and owners can view admin panel
|
||||
if (! $user->teams->contains('id', $team->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,11 +85,10 @@ class TeamPolicy
|
||||
*/
|
||||
public function manageInvitations(User $user, Team $team): bool
|
||||
{
|
||||
// Only admins and owners can manage invitations
|
||||
if (! $user->teams->contains('id', $team->id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->isAdmin() || $user->isOwner();
|
||||
return $user->isAdminOfTeam($team->id);
|
||||
}
|
||||
}
|
||||
+122
-24
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeExternalUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param (Closure(string): array<int, string>)|null $resolver
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null) {}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
@@ -38,44 +43,137 @@ class SafeExternalUrl implements ValidationRule
|
||||
}
|
||||
|
||||
$host = strtolower($host);
|
||||
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
||||
$hostForDns = rtrim($hostForIpCheck, '.');
|
||||
|
||||
// Block well-known internal hostnames
|
||||
$internalHosts = ['localhost', '0.0.0.0', '::1'];
|
||||
if (in_array($host, $internalHosts) || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
|
||||
Log::warning('External URL points to internal host', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $value,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
|
||||
$this->logBlockedHost($attribute, $value, $host);
|
||||
$fail('The :attribute must not point to internal hosts.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve hostname to IP and block private/reserved ranges
|
||||
$ip = gethostbyname($host);
|
||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
||||
if (! $this->isPublicIp($hostForIpCheck)) {
|
||||
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
|
||||
// gethostbyname returns the original hostname on failure (e.g. unresolvable)
|
||||
if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedIps = $this->resolveHost($hostForDns);
|
||||
if ($resolvedIps === []) {
|
||||
$fail('The :attribute host could not be resolved.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
Log::warning('External URL resolves to private or reserved IP', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $value,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $ip,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
foreach ($resolvedIps as $resolvedIp) {
|
||||
if (! $this->isPublicIp($resolvedIp)) {
|
||||
$this->logBlockedIp($attribute, $value, $host, $resolvedIp);
|
||||
$fail('The :attribute must not point to a private or reserved IP address.');
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeHostForIpCheck(string $host): string
|
||||
{
|
||||
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||
? substr($host, 1, -1)
|
||||
: $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveHost(string $host): array
|
||||
{
|
||||
if ($this->resolver instanceof Closure) {
|
||||
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||
if ($records === false) {
|
||||
$records = [];
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
foreach ($records as $record) {
|
||||
foreach (['ip', 'ipv6'] as $key) {
|
||||
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $record[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ipv4Addresses = @gethostbynamel($host);
|
||||
if (is_array($ipv4Addresses)) {
|
||||
foreach ($ipv4Addresses as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private function isPublicIp(string $ip): bool
|
||||
{
|
||||
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||
if ($embeddedIpv4 !== null) {
|
||||
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
}
|
||||
|
||||
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
||||
}
|
||||
|
||||
private function extractIpv4FromMappedIpv6(string $ip): ?string
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false || strlen($packed) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = substr($packed, 0, 12);
|
||||
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = unpack('C4', substr($packed, 12, 4));
|
||||
if ($parts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
private function logBlockedHost(string $attribute, string $url, string $host): void
|
||||
{
|
||||
Log::warning('External URL points to internal host', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $url,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
|
||||
{
|
||||
Log::warning('External URL resolves to private or reserved IP', [
|
||||
'attribute' => $attribute,
|
||||
'url' => $url,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $resolvedIp,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
+154
-37
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SafeWebhookUrl implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param (Closure(string): array<int, string>)|null $resolver
|
||||
*/
|
||||
public function __construct(private ?Closure $resolver = null) {}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
@@ -39,63 +44,175 @@ class SafeWebhookUrl implements ValidationRule
|
||||
}
|
||||
|
||||
$host = strtolower($host);
|
||||
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
|
||||
$hostForDns = rtrim($hostForIpCheck, '.');
|
||||
|
||||
// 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($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
|
||||
Log::warning('Webhook URL points to blocked host', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
|
||||
$this->logBlockedHost($attribute, $host);
|
||||
$fail('The :attribute must not point to localhost or internal hosts.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
|
||||
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,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
|
||||
if ($this->isBlockedIp($hostForIpCheck)) {
|
||||
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$resolvedIps = $this->resolveHost($hostForDns);
|
||||
foreach ($resolvedIps as $resolvedIp) {
|
||||
if ($this->isBlockedIp($resolvedIp)) {
|
||||
$this->logBlockedIp($attribute, $host, $resolvedIp);
|
||||
$fail('The :attribute must not point to loopback or link-local addresses.');
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function isLoopback(string $ip): bool
|
||||
private function normalizeHostForIpCheck(string $host): string
|
||||
{
|
||||
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
|
||||
? substr($host, 1, -1)
|
||||
: $host;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolveHost(string $host): array
|
||||
{
|
||||
if ($this->resolver instanceof Closure) {
|
||||
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
|
||||
}
|
||||
|
||||
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
|
||||
if ($records === false) {
|
||||
$records = [];
|
||||
}
|
||||
|
||||
$ips = [];
|
||||
foreach ($records as $record) {
|
||||
foreach (['ip', 'ipv6'] as $key) {
|
||||
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $record[$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ipv4Addresses = @gethostbynamel($host);
|
||||
if (is_array($ipv4Addresses)) {
|
||||
foreach ($ipv4Addresses as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ips[] = $ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($ips));
|
||||
}
|
||||
|
||||
private function isBlockedIp(string $ip): bool
|
||||
{
|
||||
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
|
||||
if ($embeddedIpv4 !== null) {
|
||||
return $this->isBlockedIpv4($embeddedIpv4);
|
||||
}
|
||||
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return $this->isBlockedIpv4($ip);
|
||||
}
|
||||
|
||||
return $this->isBlockedIpv6($ip);
|
||||
}
|
||||
|
||||
private function isBlockedIpv4(string $ip): bool
|
||||
{
|
||||
// 127.0.0.0/8, 0.0.0.0
|
||||
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv6 loopback
|
||||
$normalized = @inet_pton($ip);
|
||||
|
||||
return $normalized !== false && $normalized === inet_pton('::1');
|
||||
}
|
||||
|
||||
private function isLinkLocal(string $ip): bool
|
||||
{
|
||||
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$long = ip2long($ip);
|
||||
if ($long === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$long = ip2long($ip);
|
||||
$unsigned = sprintf('%u', $long);
|
||||
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
|
||||
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
|
||||
|
||||
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
|
||||
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
|
||||
}
|
||||
|
||||
private function isBlockedIpv6(string $ip): bool
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$bytes = unpack('C16', $packed);
|
||||
if ($bytes === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$firstByte = $bytes[1];
|
||||
$secondByte = $bytes[2];
|
||||
|
||||
// fe80::/10 link-local and fc00::/7 unique local addresses.
|
||||
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|
||||
|| (($firstByte & 0xFE) === 0xFC);
|
||||
}
|
||||
|
||||
private function extractIpv4FromMappedIpv6(string $ip): ?string
|
||||
{
|
||||
$packed = @inet_pton($ip);
|
||||
if ($packed === false || strlen($packed) !== 16) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$prefix = substr($packed, 0, 12);
|
||||
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = unpack('C4', substr($packed, 12, 4));
|
||||
if ($parts === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
private function logBlockedHost(string $attribute, string $host): void
|
||||
{
|
||||
Log::warning('Webhook URL points to blocked host', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
|
||||
{
|
||||
Log::warning('Webhook URL points to blocked IP range', [
|
||||
'attribute' => $attribute,
|
||||
'host' => $host,
|
||||
'resolved_ip' => $blockedIp,
|
||||
'ip' => request()->ip(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class ValidS3BucketName implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! is_string($value) || ! ValidationPatterns::isValidS3BucketName($value)) {
|
||||
$fail('The :attribute must be a valid S3 bucket name: 3-63 lowercase letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
|
||||
class DatabaseBackupFileValidator
|
||||
{
|
||||
public const ALLOWED_EXTENSIONS = [
|
||||
'sql',
|
||||
'sql.gz',
|
||||
'gz',
|
||||
'zip',
|
||||
'tar',
|
||||
'tar.gz',
|
||||
'tgz',
|
||||
'dump',
|
||||
'bak',
|
||||
'bson',
|
||||
'bson.gz',
|
||||
'archive',
|
||||
'archive.gz',
|
||||
'bz2',
|
||||
'xz',
|
||||
'dmp',
|
||||
];
|
||||
|
||||
private const DANGEROUS_EXTENSIONS = [
|
||||
'asp',
|
||||
'aspx',
|
||||
'bat',
|
||||
'bash',
|
||||
'cgi',
|
||||
'cmd',
|
||||
'com',
|
||||
'exe',
|
||||
'htm',
|
||||
'html',
|
||||
'jar',
|
||||
'js',
|
||||
'jsp',
|
||||
'php',
|
||||
'php3',
|
||||
'php4',
|
||||
'php5',
|
||||
'phtml',
|
||||
'pl',
|
||||
'ps1',
|
||||
'py',
|
||||
'rb',
|
||||
'sh',
|
||||
];
|
||||
|
||||
public static function hasAllowedExtension(string $name): bool
|
||||
{
|
||||
return self::extensionFor($name) !== null;
|
||||
}
|
||||
|
||||
public static function isUploadAllowed(UploadedFile $file, int $maxBytes): bool
|
||||
{
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$size = $file->getSize();
|
||||
|
||||
if ($size === false || $size > $maxBytes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extension = self::extensionFor($originalName);
|
||||
if ($extension === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::contentMatchesExtension($file->getPathname(), $extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a stored backup file (decompressing gzip on the fly) for PostgreSQL
|
||||
* restore directives that lead to OS command execution.
|
||||
*/
|
||||
public static function fileContainsPostgresqlProgramExecution(string $path): bool
|
||||
{
|
||||
$contents = self::readPossiblyGzippedText($path);
|
||||
|
||||
if ($contents === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::containsPostgresqlProgramExecution($contents);
|
||||
}
|
||||
|
||||
public static function containsPostgresqlProgramExecution(string $sql): bool
|
||||
{
|
||||
$withoutComments = self::stripSqlComments($sql);
|
||||
|
||||
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
|
||||
}
|
||||
|
||||
private static function extensionFor(string $name): ?string
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
$suffixes = array_map(fn (string $ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
|
||||
usort($suffixes, fn (string $a, string $b) => strlen($b) <=> strlen($a));
|
||||
|
||||
foreach ($suffixes as $suffix) {
|
||||
if (! str_ends_with($lower, $suffix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$stem = substr($lower, 0, -strlen($suffix));
|
||||
if ($stem === '' || str_ends_with($stem, '.')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = array_filter(explode('.', $stem));
|
||||
if (array_intersect($parts, self::DANGEROUS_EXTENSIONS) !== []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ltrim($suffix, '.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function contentMatchesExtension(string $path, string $extension): bool
|
||||
{
|
||||
$sample = (string) file_get_contents($path, false, null, 0, 4096);
|
||||
|
||||
return match ($extension) {
|
||||
'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample),
|
||||
'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"),
|
||||
'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"),
|
||||
'tar' => substr($sample, 257, 5) === 'ustar',
|
||||
'bz2' => str_starts_with($sample, 'BZh'),
|
||||
'xz' => str_starts_with($sample, "\xfd7zXZ\x00"),
|
||||
'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP')
|
||||
|| (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)),
|
||||
'bson' => self::looksLikeBson($path, $sample),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
private static function readPossiblyGzippedText(string $path): ?string
|
||||
{
|
||||
// Cap the scan so a huge legitimate dump cannot exhaust memory; the
|
||||
// remote pre-restore scanner inspects the full file as a second layer.
|
||||
$maxBytes = 50 * 1024 * 1024;
|
||||
|
||||
$handle = @fopen($path, 'rb');
|
||||
if ($handle === false) {
|
||||
return null;
|
||||
}
|
||||
$magic = (string) fread($handle, 2);
|
||||
fclose($handle);
|
||||
|
||||
if ($magic === "\x1f\x8b") {
|
||||
$gz = @gzopen($path, 'rb');
|
||||
if ($gz === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = '';
|
||||
while (! gzeof($gz) && strlen($data) < $maxBytes) {
|
||||
$chunk = gzread($gz, 1024 * 1024);
|
||||
if ($chunk === false || $chunk === '') {
|
||||
break;
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
gzclose($gz);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
return (string) file_get_contents($path, false, null, 0, $maxBytes);
|
||||
}
|
||||
|
||||
private static function looksLikeText(string $sample): bool
|
||||
{
|
||||
if ($sample === '' || str_contains($sample, "\0")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mb_check_encoding($sample, 'UTF-8') || mb_check_encoding($sample, 'ASCII');
|
||||
}
|
||||
|
||||
private static function looksLikeBson(string $path, string $sample): bool
|
||||
{
|
||||
if (strlen($sample) < 5) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$documentLength = unpack('V', substr($sample, 0, 4))[1] ?? 0;
|
||||
$fileSize = filesize($path) ?: 0;
|
||||
|
||||
return $documentLength >= 5 && $documentLength <= $fileSize;
|
||||
}
|
||||
|
||||
private static function stripSqlComments(string $sql): string
|
||||
{
|
||||
$sql = preg_replace('/\/\*[\s\S]*?\*\//', ' ', $sql) ?? $sql;
|
||||
|
||||
return preg_replace('/--[^\r\n]*/', ' ', $sql) ?? $sql;
|
||||
}
|
||||
}
|
||||
@@ -94,10 +94,19 @@ class ValidationPatterns
|
||||
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
|
||||
|
||||
/**
|
||||
* Pattern for Docker-compatible environment variable keys.
|
||||
* Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
|
||||
* Pattern for S3 bucket names.
|
||||
*
|
||||
* Bucket names must be 3-63 lowercase characters, start and end with a
|
||||
* letter or digit, and contain only lowercase letters, digits, dots, and
|
||||
* hyphens. Additional semantic checks live in isValidS3BucketName().
|
||||
*/
|
||||
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
|
||||
public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[a-z0-9][a-z0-9.-]*[a-z0-9]\z/';
|
||||
|
||||
/**
|
||||
* Pattern for Docker-compatible environment variable keys.
|
||||
* Environment variable keys are later interpolated into shell commands as Docker build args, so only shell-safe identifier characters are allowed.
|
||||
*/
|
||||
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u';
|
||||
|
||||
/**
|
||||
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
|
||||
@@ -164,7 +173,7 @@ class ValidationPatterns
|
||||
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
|
||||
{
|
||||
return [
|
||||
"{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
|
||||
"{$field}.regex" => "The {$label} must start with a letter or underscore and may only contain letters, numbers, underscores, and dots.",
|
||||
"{$field}.max" => "The {$label} may not be greater than :max characters.",
|
||||
];
|
||||
}
|
||||
@@ -177,6 +186,22 @@ class ValidationPatterns
|
||||
return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a valid S3 bucket name.
|
||||
*/
|
||||
public static function isValidS3BucketName(string $value): bool
|
||||
{
|
||||
if (preg_match(self::S3_BUCKET_NAME_PATTERN, $value) !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_contains($value, '..') || str_contains($value, '.-') || str_contains($value, '-.')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize and validate an environment variable key.
|
||||
*/
|
||||
|
||||
@@ -79,6 +79,7 @@ trait ExecuteRemoteCommand
|
||||
$ignore_errors = data_get($single_command, 'ignore_errors', false);
|
||||
$append = data_get($single_command, 'append', true);
|
||||
$command_hidden = data_get($single_command, 'command_hidden', false);
|
||||
$skip_command_log = data_get($single_command, 'skip_command_log', false);
|
||||
$this->save = data_get($single_command, 'save');
|
||||
if ($this->server->isNonRoot()) {
|
||||
if (str($command)->startsWith('docker exec')) {
|
||||
@@ -91,7 +92,7 @@ trait ExecuteRemoteCommand
|
||||
// Check for cancellation before executing commands
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
@@ -103,7 +104,7 @@ trait ExecuteRemoteCommand
|
||||
|
||||
while ($attempt < $maxRetries && ! $commandExecuted) {
|
||||
try {
|
||||
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden);
|
||||
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log);
|
||||
$commandExecuted = true;
|
||||
} catch (\RuntimeException|DeploymentException $e) {
|
||||
$lastError = $e;
|
||||
@@ -119,7 +120,7 @@ trait ExecuteRemoteCommand
|
||||
|
||||
// Check for cancellation during retry wait
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
|
||||
}
|
||||
}
|
||||
@@ -153,14 +154,14 @@ trait ExecuteRemoteCommand
|
||||
/**
|
||||
* Execute the actual command with process handling
|
||||
*/
|
||||
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false)
|
||||
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false)
|
||||
{
|
||||
if ($command_hidden && isset($this->application_deployment_queue)) {
|
||||
if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
|
||||
}
|
||||
|
||||
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden) {
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
|
||||
$output = str($output)->trim();
|
||||
if ($output->startsWith('╔')) {
|
||||
$output = "\n".$output;
|
||||
@@ -170,7 +171,7 @@ trait ExecuteRemoteCommand
|
||||
$sanitized_output = sanitize_utf8_text($output);
|
||||
|
||||
$new_log_entry = [
|
||||
'command' => $command_hidden ? null : $this->redact_sensitive_info($command),
|
||||
'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),
|
||||
'output' => $this->redact_sensitive_info($sanitized_output),
|
||||
'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'),
|
||||
'timestamp' => Carbon::now('UTC'),
|
||||
@@ -227,7 +228,7 @@ trait ExecuteRemoteCommand
|
||||
// Check if deployment was cancelled while command was running
|
||||
if (isset($this->application_deployment_queue)) {
|
||||
$this->application_deployment_queue->refresh();
|
||||
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
|
||||
throw new \RuntimeException('Deployment cancelled by user', 69420);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user