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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 59 additions & 13 deletions ProcessMaker/Http/Controllers/Api/DevLinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,27 +59,38 @@ 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;
}

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();

Expand Down Expand Up @@ -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)
Expand Down
31 changes: 31 additions & 0 deletions ProcessMaker/Http/Requests/DevLinkStoreRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace ProcessMaker\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use ProcessMaker\Rules\HttpOrigin;

class DevLinkStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

protected function prepareForValidation(): void
{
$this->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()],
];
}
}
32 changes: 32 additions & 0 deletions ProcessMaker/Rules/HttpOrigin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace ProcessMaker\Rules;

use Closure;
use Illuminate\Contracts\Validation\ValidationRule;

class HttpOrigin implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$parts = parse_url($value);
if ($parts === false) {
$fail(__('The instance URL must contain only an HTTP or HTTPS origin.'));

return;
}

$path = $parts['path'] ?? '';
if (
!isset($parts['scheme'], $parts['host'])
|| !in_array(strtolower($parts['scheme']), ['http', 'https'], true)
|| !in_array($path, ['', '/'], true)
|| isset($parts['query'])
|| isset($parts['fragment'])
|| isset($parts['user'])
|| isset($parts['pass'])
) {
$fail(__('The instance URL must contain only an HTTP or HTTPS origin.'));
}
}
}
9 changes: 6 additions & 3 deletions resources/js/admin/devlink/components/BundleDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<VersionCheck
:dev-link="bundle"
@updateAvailable="updateAvailable = $event"
@availabilityChanged="remoteAvailable = $event"
/>
</div>
<div class="header-actions">
Expand All @@ -37,6 +38,7 @@
<button
v-if="bundle.dev_link_id !== null"
class="btn btn-outline-secondary mr-2 dropdown-toggle"
:disabled="remoteAvailable !== true"
data-toggle="dropdown"
data-offset="5, 5"
aria-haspopup="true"
Expand All @@ -49,7 +51,7 @@
class="dropdown-menu dropdown-menu-right"
>
<a
v-if="updateAvailable"
v-if="updateAvailable && remoteAvailable"
class="dropdown-item"
href="#"
@click.prevent="reinstallBundle.show(bundle)"
Expand All @@ -58,7 +60,7 @@
{{ $t('Update Bundle') }}
</a>
<a
v-if="bundle.dev_link_id !== null"
v-if="bundle.dev_link_id !== null && remoteAvailable"
class="dropdown-item"
href="#"
@click.prevent="executeReinstall('copy')"
Expand All @@ -67,7 +69,7 @@
{{ $t('Add a Copy') }}
</a>
<a
v-if="bundle.dev_link_id !== null"
v-if="bundle.dev_link_id !== null && remoteAvailable"
class="dropdown-item"
href="#"
@click.prevent="executeReinstall('update')"
Expand Down Expand Up @@ -178,6 +180,7 @@ const route = useRoute();
const bundleId = route.params.id;
const bundleForEdit = ref({});
const updateAvailable = ref(false);
const remoteAvailable = ref(null);
const selected = ref(null);
const confirmPublishNewVersion = ref(null);

Expand Down
12 changes: 11 additions & 1 deletion resources/js/admin/devlink/components/CreateDevLinkModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
:ok-title="$t('Create')"
:cancel-title="$t('Cancel')"
>
<div v-if="status === 'error'" class="alert alert-danger" role="alert">
<div v-if="errorMessage" class="alert alert-danger" role="alert">
<div class="alert-header">
<span class="icon">!</span>
<strong>{{ errorMessage }}</strong>
</div>
</div>
<div v-else-if="status === 'error'" class="alert alert-danger" role="alert">
<div class="alert-header">
<span class="icon">!</span>
<strong>Connection Error</strong>
Expand Down Expand Up @@ -58,6 +64,10 @@ const props = defineProps({
type: String,
default: ''
},
errorMessage: {
type: String,
default: ''
},
showDetails: {
type: Boolean,
default: false
Expand Down
33 changes: 32 additions & 1 deletion resources/js/admin/devlink/components/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ onMounted(() => {
const newName = ref('');
const newUrl = ref('');
const status = ref('');
const createError = ref('');

const load = () => {
ProcessMaker.apiClient
Expand All @@ -79,10 +80,12 @@ const onNavigate = (action, data, index) => {
const clear = () => {
newName.value = '';
newUrl.value = '';
createError.value = '';
}

const create = (name, url) => {
status.value = '';
createError.value = '';
ProcessMaker.apiClient
.post('/devlink', {
name: name,
Expand Down Expand Up @@ -112,6 +115,13 @@ const create = (name, url) => {
status.value = 'error';
}
});
})
.catch((error) => {
createError.value = error.response?.data?.errors?.url?.[0]
|| error.response?.data?.errors?.name?.[0]
|| error.response?.data?.error?.message
|| error.response?.data?.message
|| vue.$t('Unable to create the linked instance.');
});
};

Expand Down Expand Up @@ -162,15 +172,35 @@ const handleFilterChange = () => {

const showCreateModal = () => {
status.value = '';
createError.value = '';
createDevLinkModal.value.show();
};

const handleNewUrlUpdate = (newValue) => {
newUrl.value = newValue;
};

const isValidInstanceUrl = (value) => {
const trimmedValue = value.trim();
if (trimmedValue.includes('?') || trimmedValue.includes('#')) {
return false;
}

try {
const url = new URL(trimmedValue);

return ['http:', 'https:'].includes(url.protocol)
&& url.hostname !== ''
&& url.pathname === '/'
&& url.username === ''
&& url.password === '';
} catch {
return false;
}
};

const urlIsValid = computed(() => {
return /^(https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(:\d{1,5})?$/.test(newUrl.value);
return isValidInstanceUrl(newUrl.value);
});

</script>
Expand Down Expand Up @@ -218,6 +248,7 @@ const urlIsValid = computed(() => {
:newUrl="newUrl"
:urlIsValid="urlIsValid"
:status="status"
:errorMessage="createError"
@clear="clear"
@create="create"
@update:newUrl="handleNewUrlUpdate"
Expand Down
20 changes: 15 additions & 5 deletions resources/js/admin/devlink/components/LocalBundles.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ const bundleModal = ref(null);
const updateBundle = ref(null);
const deleteWarningTitle = ref(vue.$t("Delete Confirmation"));
const updatesAvailable = reactive({});
const remoteAvailability = reactive({});
const refreshKey = ref(0);

const actions = [
{ value: "open-item", content: "Open" },
{ value: "increase-item", content: "Publish New Version", conditional: "if(not(dev_link_id), true, false)" },
{ value: "update-item", content: "Update Bundle", conditional: "if(update_available, true, false)" },
{ value: "reinstall-item", content: "Reinstall Bundle", conditional: "if(dev_link_id, true, false)" },
{ value: "reinstall-item", content: "Reinstall Bundle", conditional: "if(dev_link_id, remote_available, false)" },
{ value: "edit-item", content: "Edit", conditional: "if(not(dev_link_id) , true, false)" },
{ value: "delete-item", content: "Delete" },
]
Expand All @@ -44,6 +45,10 @@ const setUpdateAvailable = (bundle, updateAvailable) => {
set(updatesAvailable, bundle.id, updateAvailable);
};

const setRemoteAvailability = (bundle, available) => {
set(remoteAvailability, bundle.id, available);
};

onMounted(() => {
load();
})
Expand Down Expand Up @@ -280,17 +285,22 @@ const handleInstallationComplete = () => {
<Origin :dev-link="data.item.dev_link"></Origin>
</template>
<template #cell(version)="data">
{{ data.item.version }} <VersionCheck
:key="`version-check-${data.item.id}-${refreshKey}`"
@updateAvailable="setUpdateAvailable(data.item, $event)"
{{ data.item.version }} <VersionCheck
:key="`version-check-${data.item.id}-${refreshKey}`"
@updateAvailable="setUpdateAvailable(data.item, $event)"
@availabilityChanged="setRemoteAvailability(data.item, $event)"
:dev-link="data.item">
</VersionCheck>
</template>
<template #cell(menu)="data">
<EllipsisMenu
class="ellipsis-devlink"
:actions="actions"
:data="{ ...data.item, update_available: updatesAvailable[data.item.id] ?? false }"
:data="{
...data.item,
update_available: updatesAvailable[data.item.id] ?? false,
remote_available: remoteAvailability[data.item.id] ?? false,
}"
:custom-button="customButton"
@navigate="onNavigate"
/>
Expand Down
Loading
Loading