From f7414915bd01ada3bfe78fe7ca19a3ff6ea411e5 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 21 Jul 2026 11:34:55 -0600 Subject: [PATCH 1/2] FOUR-32304: Prevent duplicate DevLinks from breaking bundle lists --- .../Controllers/Api/DevLinkController.php | 72 ++++-- .../Http/Requests/DevLinkStoreRequest.php | 31 +++ ProcessMaker/Rules/HttpOrigin.php | 29 +++ .../admin/devlink/components/BundleDetail.vue | 9 +- .../devlink/components/CreateDevLinkModal.vue | 12 +- .../js/admin/devlink/components/Index.vue | 12 + .../admin/devlink/components/LocalBundles.vue | 20 +- .../admin/devlink/components/VersionCheck.vue | 46 +++- resources/lang/en.json | 4 + tests/Feature/Api/DevLinkTest.php | 209 +++++++++++++++++- 10 files changed, 416 insertions(+), 28 deletions(-) create mode 100644 ProcessMaker/Http/Requests/DevLinkStoreRequest.php create mode 100644 ProcessMaker/Rules/HttpOrigin.php diff --git a/ProcessMaker/Http/Controllers/Api/DevLinkController.php b/ProcessMaker/Http/Controllers/Api/DevLinkController.php index 01a56364fe..b7ec2297b7 100644 --- a/ProcessMaker/Http/Controllers/Api/DevLinkController.php +++ b/ProcessMaker/Http/Controllers/Api/DevLinkController.php @@ -3,12 +3,15 @@ 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\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\CompileUI; use ProcessMaker\Jobs\DevLinkInstall; @@ -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(); @@ -373,7 +387,39 @@ public function installRemoteAsset(Request $request, DevLink $devLink) public function remoteBundleVersion(DevLink $devLink, int $remoteBundleId) { - return $devLink->remoteBundle($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 + { + $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) 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/Rules/HttpOrigin.php b/ProcessMaker/Rules/HttpOrigin.php new file mode 100644 index 0000000000..582ac8583b --- /dev/null +++ b/ProcessMaker/Rules/HttpOrigin.php @@ -0,0 +1,29 @@ +
@@ -37,6 +38,7 @@