diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 8994184d1a..b7ec2297b7 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -3,15 +3,16 @@ namespace ProcessMaker\Http\Controllers\Api; use Illuminate\Database\Eloquent\ModelNotFoundException; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Http\Request; -use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Notification; use Illuminate\Validation\Rule; use ProcessMaker\Events\CustomizeUiUpdated; use ProcessMaker\Exception\ValidationException; use ProcessMaker\Http\Controllers\Controller; +use ProcessMaker\Http\Requests\DevLinkStoreRequest; use ProcessMaker\Http\Resources\ApiCollection; -use ProcessMaker\Jobs\CompileSass; use ProcessMaker\Jobs\CompileUI; use ProcessMaker\Jobs\DevLinkInstall; use ProcessMaker\Models\Bundle; @@ -23,6 +24,8 @@ use ProcessMaker\Models\SettingsMenus; use ProcessMaker\Models\User; use ProcessMaker\Notifications\BundleUpdatedNotification; +use ProcessMaker\Package\PackageDynamicUI\Models\Dashboard; +use ProcessMaker\Package\PackageDynamicUI\Models\Menu; class DevLinkController extends Controller { @@ -56,20 +59,24 @@ public function show(DevLink $devLink) return $devLink; } - public function store(Request $request) + public function store(DevLinkStoreRequest $request) { - $request->validate([ - 'name' => ['required'], - 'url' => ['required', 'url'], - ]); - $devLink = DevLink::where('name', $request->input('name'))->first(); - if ($devLink) { - $devLink->url = $request->input('url'); - } else { - $devLink = new DevLink(); - $devLink->name = $request->input('name'); - $devLink->url = $request->input('url'); + $normalizedUrl = $this->normalizeUrl($request->input('url')); + $existingDevLink = DevLink::query() + ->get(['id', 'name', 'url']) + ->first(fn (DevLink $devLink) => $this->normalizeUrl($devLink->url) === $normalizedUrl); + if ($existingDevLink) { + throw ValidationException::withMessages([ + 'url' => __( + 'This instance is already linked as :name. Open or reconnect the existing connection.', + ['name' => $existingDevLink->name] + ), + ]); } + + $devLink = new DevLink(); + $devLink->name = $request->input('name'); + $devLink->url = $normalizedUrl; $devLink->saveOrFail(); return $devLink; @@ -77,6 +84,13 @@ public function store(Request $request) public function update(Request $request, DevLink $devLink) { + $request->merge([ + 'name' => trim((string) $request->input('name')), + ]); + $request->validate([ + 'name' => ['required', 'string', Rule::unique('dev_links', 'name')->ignore($devLink->id)], + ]); + $devLink->name = $request->input('name'); $devLink->saveOrFail(); @@ -180,7 +194,7 @@ public function increaseBundleVersion(Bundle $bundle) return $bundle; } - public function bundleUpdated($bundleId, $token) + public function bundleUpdated(int $bundleId, string $token) { try { $bundle = Bundle::where('remote_id', $bundleId)->firstOrFail(); @@ -208,7 +222,7 @@ public function deleteBundle(Bundle $bundle) $bundle->delete(); } - public function installRemoteBundle(Request $request, DevLink $devLink, $remoteBundleId) + public function installRemoteBundle(Request $request, DevLink $devLink, int $remoteBundleId) { $updateType = $request->input('updateType', DevLinkInstall::MODE_UPDATE); DevLinkInstall::dispatch( @@ -342,7 +356,7 @@ public function addSharedAsset(Request $request) $sharedAsset->saveOrFail(); } - public function removeSharedAsset($id) + public function removeSharedAsset(int $id) { $deleted = Setting::destroy($id); @@ -371,9 +385,41 @@ public function installRemoteAsset(Request $request, DevLink $devLink) ]; } - public function remoteBundleVersion(DevLink $devLink, $remoteBundleId) + public function remoteBundleVersion(DevLink $devLink, int $remoteBundleId) + { + try { + $payload = $devLink->remoteBundle($remoteBundleId)->json(); + } catch (RequestException|ConnectionException $e) { + return [ + 'available' => false, + 'version' => null, + ]; + } + + if (!is_array($payload)) { + return [ + 'available' => false, + 'version' => null, + ]; + } + + $payload['available'] = true; + + return $payload; + } + + private function normalizeUrl(string $url): string { - return $devLink->remoteBundle($remoteBundleId); + $parts = parse_url(trim($url)); + $scheme = strtolower($parts['scheme']); + $host = strtolower($parts['host']); + $port = $parts['port'] ?? null; + + if (($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) { + $port = null; + } + + return $scheme . '://' . $host . ($port === null ? '' : ':' . $port); } public function deleteBundleAsset(BundleAsset $bundleAsset) @@ -390,80 +436,70 @@ public function deleteBundleSetting(BundleSetting $bundleSetting) return response()->json(['message' => 'Bundle setting deleted.'], 200); } - public function getBundleSetting(Bundle $bundle, $settingKey) + public function getBundleSetting(Bundle $bundle, string $settingKey) + { + return $bundle->settings()->where('setting', $settingKey)->first(); + } + + public function getBundleSettingPreview(Bundle $bundle, string $settingKey): array { - $setting = $bundle->settings()->where('setting', $settingKey)->first(); + abort_unless(in_array($settingKey, ['ui_dashboards', 'ui_menus'], true), 404); - return $setting; + return $bundle->settingPreview($settingKey); } - public function getBundleAllSettings($settingKey) + public function getBundleAllSettings(string $settingKey) { - if ($settingKey === 'ui_settings') { - return Setting::whereIn('key', ['css-override', 'login-footer', 'logo-alt-text']) - ->get(); + return match ($settingKey) { + 'ui_dashboards' => $this->getAvailableDashboards(), + 'ui_menus' => $this->getAvailableMenus(), + 'ui_settings' => Setting::whereIn('key', ['css-override', 'login-footer', 'logo-alt-text'])->get(), + default => Setting::where([ + ['group_id', SettingsMenus::getId($settingKey)], + ['hidden', 0], + ])->get(), + }; + } + + private function getAvailableDashboards() + { + if (!class_exists(Dashboard::class)) { + return []; } - return Setting::where([ - ['group_id', SettingsMenus::getId($settingKey)], - ['hidden', 0], - ])->get(); + return Dashboard::query() + ->orderBy('title') + ->get(['id', 'title']) + ->map(function (Dashboard $dashboard) { + return [ + 'key' => $dashboard->id, + 'name' => $dashboard->title, + ]; + }) + ->values(); } - public function refreshUi() + private function getAvailableMenus() { - CompileUI::dispatch(auth()->user()?->id); - CustomizeUiUpdated::dispatch([], [], false); + if (!class_exists(Menu::class)) { + return []; + } + + return Menu::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(function (Menu $menu) { + return [ + 'key' => $menu->id, + 'name' => $menu->name, + ]; + }) + ->values(); } - private function writeColors($data) + public function refreshUi(Request $request) { - // Now generate the _colors.scss file - $contents = "// Changed theme colors\n"; - foreach ($data as $value) { - $contents .= $value->id . ': ' . $value->value . ";\n"; - } - File::put(app()->resourcePath('sass') . '/_colors.scss', $contents); - } - - /** - * Write variables font in file - * - * @param $sansSerif - * @param $serif - */ - private function writeFonts($sansSerif) - { - $sansSerif = $sansSerif ? $sansSerif : $this->sansSerifFontDefault(); - // Generate the _fonts.scss file - $contents = "// Changed theme fonts\n"; - $contents .= '$font-family-sans-serif: ' . $sansSerif['id'] . " !default;\n"; - File::put(app()->resourcePath('sass') . '/_fonts.scss', $contents); - } - - /** - * run jobs compile - */ - private function compileSass($userId) - { - // Compile the Sass files - $this->dispatch(new CompileSass([ - 'tag' => 'sidebar', - 'origin' => 'resources/sass/sidebar/sidebar.scss', - 'target' => 'public/css/sidebar.css', - 'user' => $userId, - ])); - $this->dispatch(new CompileSass([ - 'tag' => 'app', - 'origin' => 'resources/sass/app.scss', - 'target' => 'public/css/app.css', - 'user' => $userId, - ])); - $this->dispatch(new CompileSass([ - 'tag' => 'queues', - 'origin' => 'resources/sass/admin/queues.scss', - 'target' => 'public/css/admin/queues.css', - 'user' => $userId, - ])); + CompileUI::dispatch($request->user()?->id); + CustomizeUiUpdated::dispatch([], [], false); } } diff --git a/ProcessMaker/Http/Requests/DevLinkStoreRequest.php b/ProcessMaker/Http/Requests/DevLinkStoreRequest.php new file mode 100644 index 0000000000..6d6c1d5115 --- /dev/null +++ b/ProcessMaker/Http/Requests/DevLinkStoreRequest.php @@ -0,0 +1,31 @@ +merge([ + 'name' => trim((string) $this->input('name')), + 'url' => trim((string) $this->input('url')), + ]); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', Rule::unique('dev_links', 'name')], + 'url' => ['bail', 'required', 'url', new HttpOrigin()], + ]; + } +} diff --git a/ProcessMaker/ImportExport/Options.php b/ProcessMaker/ImportExport/Options.php index f70dcce89c..084489c4fc 100644 --- a/ProcessMaker/ImportExport/Options.php +++ b/ProcessMaker/ImportExport/Options.php @@ -34,9 +34,12 @@ class Options public $options; - public function __construct(array $options) + public ?int $importingUserId; + + public function __construct(array $options, ?int $importingUserId = null) { $this->options = $options; + $this->importingUserId = $importingUserId; } public function get($name, $uuid) diff --git a/ProcessMaker/Models/Bundle.php b/ProcessMaker/Models/Bundle.php index a62545e499..98f6328ab5 100644 --- a/ProcessMaker/Models/Bundle.php +++ b/ProcessMaker/Models/Bundle.php @@ -20,6 +20,13 @@ class Bundle extends ProcessMakerModel implements HasMedia use HasFactory; use InteractsWithMedia; + private const SETTING_PREVIEW_PAYLOAD_TYPES = [ + 'ui_dashboards' => 'dashboard_package', + 'ui_menus' => 'menu_package', + ]; + + private const SETTINGS_PAYLOADS_COMPLETE_PROPERTY = 'settings_payloads_complete'; + protected $guarded = ['id']; protected $appends = ['asset_count']; @@ -104,6 +111,92 @@ public function exportSettingPayloads() }); } + public function settingPreview(string $settingKey): array + { + if (!array_key_exists($settingKey, self::SETTING_PREVIEW_PAYLOAD_TYPES)) { + throw new \InvalidArgumentException('Unsupported bundle setting preview.'); + } + + $bundleSetting = $this->settings()->where('setting', $settingKey)->first(); + $selection = match (true) { + $bundleSetting === null => 'none', + $bundleSetting->config === null => 'all', + default => 'partial', + }; + + $preview = [ + 'setting' => $settingKey, + 'selection' => $selection, + 'available' => true, + 'items' => [], + ]; + + if ($selection === 'none') { + return $preview; + } + + $payloads = $this->readNewestPayloads(); + if ($payloads === null) { + $preview['available'] = false; + + return $preview; + } + + $payloadType = self::SETTING_PREVIEW_PAYLOAD_TYPES[$settingKey]; + $items = []; + foreach ($payloads as $payload) { + if (!is_array($payload) || ($payload['type'] ?? null) !== $payloadType) { + continue; + } + + $key = $payload['root'] ?? null; + $name = $payload['name'] ?? null; + if (!is_string($key) || !is_string($name)) { + continue; + } + + $items[$key] = [ + 'key' => $key, + 'name' => $name, + ]; + } + + $preview['items'] = array_values($items); + usort($preview['items'], function (array $left, array $right) { + return strcasecmp($left['name'], $right['name']) + ?: strcmp($left['key'], $right['key']); + }); + + return $preview; + } + + private function readNewestPayloads(): ?array + { + $media = $this->newestVersionFile(); + if ( + $media === null + || $media->getCustomProperty(self::SETTINGS_PAYLOADS_COMPLETE_PROPERTY) !== true + || !is_readable($media->getPath()) + ) { + return null; + } + + $compressedPayloads = file_get_contents($media->getPath()); + $payloads = null; + if ($compressedPayloads !== false && str_starts_with($compressedPayloads, "\x1f\x8b")) { + $decodedPayloads = gzdecode($compressedPayloads); + if ($decodedPayloads !== false) { + try { + $payloads = json_decode($decodedPayloads, true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException) { + $payloads = null; + } + } + } + + return is_array($payloads) ? $payloads : null; + } + public function syncAssets($assets) { $assetKeys = []; @@ -322,8 +415,9 @@ public function filesSortedByVersion() $media->getCustomProperty('version'), ]; })->sort(function ($a, $b) { - // newest versions first - return version_compare($a[1], $b[1]) * -1; + $versionComparison = version_compare($a[1], $b[1]) * -1; + + return $versionComparison ?: $b[0]->getKey() <=> $a[0]->getKey(); })->map(function ($item) { return $item[0]; }); @@ -341,18 +435,10 @@ public function savePayloadsToFile(array $payloads, array $payloadsSettings, $lo } $logger->status('Saving the bundle locally'); - // Filter and extract export payloads from nested array - $exportPayloads = array_filter($payloadsSettings, function ($payload) { - return !empty($payload[0]) && isset($payload[0]['export']); - }); - - // Extract payloads from nested array structure - $flattenedExportPayloads = array_map(function ($payload) { - return $payload[0]; // Tomar solo el primer elemento del array anidado - }, array_values($exportPayloads)); + $exportPayloads = $this->extractSettingExportPayloads($payloadsSettings); - if (!empty($flattenedExportPayloads)) { - $payloads = array_merge($payloads, $flattenedExportPayloads); + if (!empty($exportPayloads)) { + $payloads = array_merge($payloads, $exportPayloads); } $this->addMediaFromString( @@ -360,10 +446,13 @@ public function savePayloadsToFile(array $payloads, array $payloadsSettings, $lo json_encode($payloads) ), )->usingFileName('payloads.json.gz') - ->withCustomProperties(['version' => $this->version]) + ->withCustomProperties([ + 'version' => $this->version, + self::SETTINGS_PAYLOADS_COMPLETE_PROPERTY => true, + ]) ->toMediaCollection(); - // Keep only the 3 most recent versions + // Keep only the 3 most recent snapshots $count = 0; foreach ($this->filesSortedByVersion() as $media) { if ($count >= 3) { @@ -388,27 +477,50 @@ public function installSettings($settings) public function installSettingsPayloads(array $payloads, $mode, $logger = null) { + if ($logger === null) { + $logger = new Logger(); + } + $options = new Options([ 'mode' => $mode, - ]); + ], $logger->userId); - $assets = []; - foreach ($payloads as $payload) { - // Verify the type of payload without considering the order - if (!empty($payload) && is_array($payload[0])) { - // If it is a settings payload - if (isset($payload[0]['setting_type'])) { - $this->processSettingsPayload($payload); - } + foreach ($payloads as $payloadGroup) { + $settingPayloads = array_filter($payloadGroup, function ($payload) { + return is_array($payload) && isset($payload['setting_type']); + }); + + if (!empty($settingPayloads)) { + $this->processSettingsPayload($settingPayloads); + } + } + + $exportPayloads = $this->extractSettingExportPayloads($payloads); + if (empty($exportPayloads)) { + return; + } + + $logger->status('Installing bundle settings on the this instance'); + $logger->setSteps($exportPayloads); - // If it is an export payload (it can be the same or another element) - if (isset($payload[0]['export'])) { - $logger->status('Installing bundle settings on the this instance'); - $logger->setSteps($payload); - $assets[] = DevLink::import($payload[0], $options, $logger); + foreach ($exportPayloads as $payload) { + DevLink::import($payload, $options, $logger); + } + } + + private function extractSettingExportPayloads(array $payloadGroups): array + { + $exportPayloads = []; + + foreach ($payloadGroups as $payloadGroup) { + foreach ($payloadGroup as $payload) { + if (is_array($payload) && isset($payload['export'])) { + $exportPayloads[] = $payload; } } } + + return $exportPayloads; } // Auxiliary method to process the settings @@ -463,7 +575,7 @@ public function install(array $payloads, $mode, $logger = null, $reinstall = fal $options = new Options([ 'mode' => $mode, - ]); + ], $logger->userId); $assets = []; foreach ($payloads as $payload) { $assets[] = DevLink::import($payload, $options, $logger); diff --git a/ProcessMaker/Rules/HttpOrigin.php b/ProcessMaker/Rules/HttpOrigin.php new file mode 100644 index 0000000000..461fafd3ff --- /dev/null +++ b/ProcessMaker/Rules/HttpOrigin.php @@ -0,0 +1,32 @@ +