This is an automated email from the ASF dual-hosted git repository.

vishesh92 pushed a commit to branch 4.20
in repository https://gitbox.apache.org/repos/asf/cloudstack.git


The following commit(s) were added to refs/heads/4.20 by this push:
     new ff9ee24d125 Fix local upload from browser failing due to ssvm cert not 
trusted (#13204)
ff9ee24d125 is described below

commit ff9ee24d1250c9d394ae60af8c70120416045691
Author: Abhisar Sinha <[email protected]>
AuthorDate: Tue May 26 13:27:33 2026 +0530

    Fix local upload from browser failing due to ssvm cert not trusted (#13204)
---
 ui/public/locales/en.json                       |   5 +
 ui/src/style/vars.less                          |   2 +-
 ui/src/utils/ssvmProbe.js                       |  30 ++++++
 ui/src/views/image/RegisterOrUploadIso.vue      |  50 +++++++--
 ui/src/views/image/RegisterOrUploadTemplate.vue |  45 +++++++-
 ui/src/views/storage/UploadLocalVolume.vue      | 133 ++++++++++++++++--------
 6 files changed, 208 insertions(+), 57 deletions(-)

diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 3160e00ba30..008bf59b3ca 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -1617,6 +1617,8 @@
 "label.offeringid": "Offering ID",
 "label.offeringtype": "Compute Offering type",
 "label.ok": "OK",
+"label.ssvm.open.cert.page": "Open Certificate Page",
+"label.retry.upload": "Retry Upload",
 "label.only.end.date.and.time": "Only end date and time",
 "label.only.start.date.and.time": "Only start date and time",
 "label.open.documentation": "Open documentation",
@@ -3667,6 +3669,9 @@
 "message.upload.iso.failed.description": "Failed to upload ISO.",
 "message.upload.template.failed.description": "Failed to upload Template",
 "message.upload.volume.failed": "Volume upload failed",
+"message.ssvm.cert.untrusted": "Unable to reach the upload server.",
+"message.ssvm.cert.trust.instructions": "The upload server may be using a 
self-signed or untrusted certificate. Click 'Open Certificate Page' to open the 
server in a new browser tab, accept the certificate warning, then return here 
and click 'Retry Upload'. If the server remains unreachable, contact your 
administrator.",
+"message.ssvm.unreachable.retry": "The upload server is still unreachable. If 
it uses a self-signed certificate, please accept it in the opened tab and try 
again.",
 "message.user.not.permitted.api": "User is not permitted to use the API",
 "message.validate.equalto": "Please enter the same value again.",
 "message.validate.max": "Please enter a value less than or equal to {0}.",
diff --git a/ui/src/style/vars.less b/ui/src/style/vars.less
index de2d494c878..133244473e2 100644
--- a/ui/src/style/vars.less
+++ b/ui/src/style/vars.less
@@ -355,7 +355,7 @@ a {
   text-align: right;
   padding-top: 15px;
 
-  button {
+  button, a.ant-btn {
     margin-right: 5px;
   }
 }
diff --git a/ui/src/utils/ssvmProbe.js b/ui/src/utils/ssvmProbe.js
new file mode 100644
index 00000000000..55690aea898
--- /dev/null
+++ b/ui/src/utils/ssvmProbe.js
@@ -0,0 +1,30 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+const SSVM_PROBE_TIMEOUT_MS = 5000
+export async function probeSsvmCert (origin) {
+  const controller = new AbortController()
+  const timeoutId = setTimeout(() => controller.abort(), SSVM_PROBE_TIMEOUT_MS)
+  try {
+    await fetch(origin, { method: 'HEAD', mode: 'no-cors', signal: 
controller.signal })
+    return true
+  } catch (e) {
+    return false
+  } finally {
+    clearTimeout(timeoutId)
+  }
+}
diff --git a/ui/src/views/image/RegisterOrUploadIso.vue 
b/ui/src/views/image/RegisterOrUploadIso.vue
index 37ae369727f..1984a6a6144 100644
--- a/ui/src/views/image/RegisterOrUploadIso.vue
+++ b/ui/src/views/image/RegisterOrUploadIso.vue
@@ -19,11 +19,27 @@
   <div
     class="form-layout"
     @keyup.ctrl.enter="handleSubmit">
-    <span v-if="uploadPercentage > 0">
+    <span v-if="uploading">
       <loading-outlined />
       {{ $t('message.upload.file.processing') }}
       <a-progress :percent="uploadPercentage" />
     </span>
+    <div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
+      <a-alert
+        type="warning"
+        show-icon
+        :message="$t('message.ssvm.cert.untrusted')"
+        :description="$t('message.ssvm.cert.trust.instructions')" />
+      <div :span="24" class="action-button">
+        <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+        <a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
+          {{ $t('label.ssvm.open.cert.page') }}
+        </a-button>
+        <a-button type="primary" :loading="loading" @click="retryUpload">
+          {{ $t('label.retry.upload') }}
+        </a-button>
+      </div>
+    </div>
     <a-spin :spinning="loading" v-else>
       <a-form
         :ref="formRef"
@@ -311,6 +327,7 @@ import { api } from '@/api'
 import store from '@/store'
 import { axios } from '../../utils/request'
 import { mixinForm } from '@/utils/mixin'
+import { probeSsvmCert } from '@/utils/ssvmProbe'
 import ResourceIcon from '@/components/view/ResourceIcon'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
 
@@ -343,9 +360,12 @@ export default {
       userdatapolicy: null,
       userdatapolicylist: {},
       loading: false,
+      uploading: false,
       allowed: false,
       uploadParams: null,
       uploadPercentage: 0,
+      ssvmCertUntrusted: false,
+      ssvmOrigin: '',
       currentForm: ['plus-outlined', 
'PlusOutlined'].includes(this.action.currentAction.icon) ? 'Create' : 'Upload',
       domains: [],
       accounts: [],
@@ -489,6 +509,17 @@ export default {
       this.form.file = file
       return false
     },
+    async retryUpload () {
+      this.loading = true
+      const reachable = await probeSsvmCert(this.ssvmOrigin)
+      this.loading = false
+      if (!reachable) {
+        this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
+        return
+      }
+      this.ssvmCertUntrusted = false
+      this.handleUpload()
+    },
     handleUpload () {
       const { fileList } = this
       if (this.fileList.length > 1) {
@@ -502,6 +533,7 @@ export default {
       fileList.forEach(file => {
         formData.append('files[]', file)
       })
+      this.uploading = true
       this.uploadPercentage = 0
       axios.post(this.uploadParams.postURL,
         formData,
@@ -529,6 +561,8 @@ export default {
           description: `${this.$t('message.upload.iso.failed.description')} -  
${e}`,
           duration: 0
         })
+      }).finally(() => {
+        this.uploading = false
       })
     },
     handleSubmit (e) {
@@ -583,18 +617,18 @@ export default {
           }
           params.format = 'ISO'
           this.loading = true
-          api('getUploadParamsForIso', params).then(json => {
+          api('getUploadParamsForIso', params).then(async json => {
             this.uploadParams = (json.postuploadisoresponse && 
json.postuploadisoresponse.getuploadparams) ? 
json.postuploadisoresponse.getuploadparams : ''
-            const response = this.handleUpload()
             if (this.userdataid !== null) {
               this.linkUserdataToTemplate(this.userdataid, 
json.postuploadisoresponse.iso[0].id)
             }
-            if (response === 'upload successful') {
-              this.$notification.success({
-                message: this.$t('message.success.upload'),
-                description: this.$t('message.success.upload.iso.description')
-              })
+            this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
+            const trusted = await probeSsvmCert(this.ssvmOrigin)
+            if (!trusted) {
+              this.ssvmCertUntrusted = true
+              return
             }
+            this.handleUpload()
           }).catch(error => {
             this.$notifyError(error)
           }).finally(() => {
diff --git a/ui/src/views/image/RegisterOrUploadTemplate.vue 
b/ui/src/views/image/RegisterOrUploadTemplate.vue
index 3ada9f6fd53..1267e5d45c1 100644
--- a/ui/src/views/image/RegisterOrUploadTemplate.vue
+++ b/ui/src/views/image/RegisterOrUploadTemplate.vue
@@ -19,11 +19,27 @@
   <div
     :class="'form-layout'"
     @keyup.ctrl.enter="handleSubmit">
-    <span v-if="uploadPercentage > 0">
+    <span v-if="uploading">
       <loading-outlined />
       {{ $t('message.upload.file.processing') }}
       <a-progress :percent="uploadPercentage" />
     </span>
+    <div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
+      <a-alert
+        type="warning"
+        show-icon
+        :message="$t('message.ssvm.cert.untrusted')"
+        :description="$t('message.ssvm.cert.trust.instructions')" />
+      <div :span="24" class="action-button">
+        <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+        <a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
+          {{ $t('label.ssvm.open.cert.page') }}
+        </a-button>
+        <a-button type="primary" :loading="loading" @click="retryUpload">
+          {{ $t('label.retry.upload') }}
+        </a-button>
+      </div>
+    </div>
     <a-spin :spinning="loading" v-else>
       <a-form
         :ref="formRef"
@@ -472,6 +488,7 @@ import { api } from '@/api'
 import store from '@/store'
 import { axios } from '../../utils/request'
 import { mixinForm } from '@/utils/mixin'
+import { probeSsvmCert } from '@/utils/ssvmProbe'
 import ResourceIcon from '@/components/view/ResourceIcon'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
 
@@ -497,6 +514,8 @@ export default {
       uploadPercentage: 0,
       uploading: false,
       fileList: [],
+      ssvmCertUntrusted: false,
+      ssvmOrigin: '',
       zones: {},
       defaultZone: '',
       hyperVisor: {},
@@ -610,12 +629,24 @@ export default {
       this.form.file = file
       return false
     },
+    async retryUpload () {
+      this.loading = true
+      const reachable = await probeSsvmCert(this.ssvmOrigin)
+      this.loading = false
+      if (!reachable) {
+        this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
+        return
+      }
+      this.ssvmCertUntrusted = false
+      this.handleUpload()
+    },
     handleUpload () {
       const { fileList } = this
       const formData = new FormData()
       fileList.forEach(file => {
         formData.append('files[]', file)
       })
+      this.uploading = true
       this.uploadPercentage = 0
       axios.post(this.uploadParams.postURL,
         formData,
@@ -639,6 +670,8 @@ export default {
         this.closeAction()
       }).catch(e => {
         this.$notifyError(e)
+      }).finally(() => {
+        this.uploading = false
       })
     },
     fetchCustomHypervisorName () {
@@ -1124,12 +1157,18 @@ export default {
               duration: 0
             })
           }
-          api('getUploadParamsForTemplate', params).then(json => {
+          api('getUploadParamsForTemplate', params).then(async json => {
             this.uploadParams = (json.postuploadtemplateresponse && 
json.postuploadtemplateresponse.getuploadparams) ? 
json.postuploadtemplateresponse.getuploadparams : ''
-            this.handleUpload()
             if (this.userdataid !== null) {
               this.linkUserdataToTemplate(this.userdataid, 
json.postuploadtemplateresponse.template[0].id)
             }
+            this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
+            const trusted = await probeSsvmCert(this.ssvmOrigin)
+            if (!trusted) {
+              this.ssvmCertUntrusted = true
+              return
+            }
+            this.handleUpload()
           }).catch(error => {
             this.$notifyError(error)
           }).finally(() => {
diff --git a/ui/src/views/storage/UploadLocalVolume.vue 
b/ui/src/views/storage/UploadLocalVolume.vue
index 3a0bf4e129f..b7303117e5a 100644
--- a/ui/src/views/storage/UploadLocalVolume.vue
+++ b/ui/src/views/storage/UploadLocalVolume.vue
@@ -16,13 +16,29 @@
 // under the License.
 
 <template>
-  <div class="form-layout" v-ctrl-enter="handleSubmit">
-    <span v-if="uploadPercentage > 0">
+  <div class="form-layout">
+    <span v-if="uploading">
       <loading-outlined />
       {{ $t('message.upload.file.processing') }}
       <a-progress :percent="uploadPercentage" />
     </span>
-    <a-spin :spinning="loading" v-else>
+    <div v-else-if="ssvmCertUntrusted" class="ssvm-cert-warning">
+      <a-alert
+        type="warning"
+        show-icon
+        :message="$t('message.ssvm.cert.untrusted')"
+        :description="$t('message.ssvm.cert.trust.instructions')" />
+      <div :span="24" class="action-button">
+        <a-button @click="closeAction">{{ $t('label.cancel') }}</a-button>
+        <a-button :href="ssvmOrigin" target="_blank" rel="noopener noreferrer">
+          {{ $t('label.ssvm.open.cert.page') }}
+        </a-button>
+        <a-button type="primary" :loading="loading" @click="retryUpload">
+          {{ $t('label.retry.upload') }}
+        </a-button>
+      </div>
+    </div>
+    <a-spin :spinning="loading" v-else v-ctrl-enter="handleSubmit">
       <a-form
         :ref="formRef"
         :model="form"
@@ -156,6 +172,7 @@ import { ref, reactive, toRaw } from 'vue'
 import { api } from '@/api'
 import { axios } from '../../utils/request'
 import { mixinForm } from '@/utils/mixin'
+import { probeSsvmCert } from '@/utils/ssvmProbe'
 import ResourceIcon from '@/components/view/ResourceIcon'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
 import InfiniteScrollSelect from 
'@/components/widgets/InfiniteScrollSelect.vue'
@@ -178,7 +195,10 @@ export default {
       customDiskOffering: false,
       isCustomizedDiskIOps: false,
       loading: false,
-      uploadPercentage: 0
+      uploading: false,
+      uploadPercentage: 0,
+      ssvmCertUntrusted: false,
+      ssvmOrigin: ''
     }
   },
   beforeCreate () {
@@ -267,6 +287,63 @@ export default {
       this.form.account = accountName
       this.account = accountName
     },
+    async retryUpload () {
+      this.loading = true
+      const reachable = await probeSsvmCert(this.ssvmOrigin)
+      this.loading = false
+      if (!reachable) {
+        this.$message.warning(this.$t('message.ssvm.unreachable.retry'))
+        return
+      }
+      this.ssvmCertUntrusted = false
+      this.handleUpload()
+    },
+    handleUpload () {
+      if (this.fileList.length > 1) {
+        this.$notification.error({
+          message: this.$t('message.upload.volume.failed'),
+          description: this.$t('message.upload.file.limit'),
+          duration: 0
+        })
+        return
+      }
+      const { fileList } = this
+      const formData = new FormData()
+      fileList.forEach(file => {
+        formData.append('files[]', file)
+      })
+      this.uploading = true
+      this.uploadPercentage = 0
+      axios.post(this.uploadParams.postURL,
+        formData,
+        {
+          headers: {
+            'content-type': 'multipart/form-data',
+            'x-signature': this.uploadParams.signature,
+            'x-expires': this.uploadParams.expires,
+            'x-metadata': this.uploadParams.metadata
+          },
+          onUploadProgress: (progressEvent) => {
+            this.uploadPercentage = Number(parseFloat(100 * 
progressEvent.loaded / progressEvent.total).toFixed(1))
+          },
+          timeout: 86400000
+        }).then((json) => {
+        this.$notification.success({
+          message: this.$t('message.success.upload'),
+          description: this.$t('message.success.upload.volume.description')
+        })
+        this.closeAction()
+      }).catch(e => {
+        this.$notification.error({
+          message: this.$t('message.upload.failed'),
+          description: `${this.$t('message.upload.volume.failed')} -  ${e}`,
+          duration: 0
+        })
+      }).finally(() => {
+        this.uploading = false
+        this.loading = false
+      })
+    },
     handleSubmit (e) {
       e.preventDefault()
       if (this.loading) return
@@ -286,49 +363,15 @@ export default {
         }
         params.domainId = this.domainId
         this.loading = true
-        api('getUploadParamsForVolume', params).then(json => {
+        api('getUploadParamsForVolume', params).then(async json => {
           this.uploadParams = json.postuploadvolumeresponse?.getuploadparams 
|| ''
-          const { fileList } = this
-          if (this.fileList.length > 1) {
-            this.$notification.error({
-              message: this.$t('message.upload.volume.failed'),
-              description: this.$t('message.upload.file.limit'),
-              duration: 0
-            })
+          this.ssvmOrigin = new URL(this.uploadParams.postURL).origin
+          const trusted = await probeSsvmCert(this.ssvmOrigin)
+          if (!trusted) {
+            this.ssvmCertUntrusted = true
+            return
           }
-          const formData = new FormData()
-          fileList.forEach(file => {
-            formData.append('files[]', file)
-          })
-          this.uploadPercentage = 0
-          axios.post(this.uploadParams.postURL,
-            formData,
-            {
-              headers: {
-                'content-type': 'multipart/form-data',
-                'x-signature': this.uploadParams.signature,
-                'x-expires': this.uploadParams.expires,
-                'x-metadata': this.uploadParams.metadata
-              },
-              onUploadProgress: (progressEvent) => {
-                this.uploadPercentage = Number(parseFloat(100 * 
progressEvent.loaded / progressEvent.total).toFixed(1))
-              },
-              timeout: 86400000
-            }).then((json) => {
-            this.$notification.success({
-              message: this.$t('message.success.upload'),
-              description: this.$t('message.success.upload.volume.description')
-            })
-            this.closeAction()
-          }).catch(e => {
-            this.$notification.error({
-              message: this.$t('message.upload.failed'),
-              description: `${this.$t('message.upload.volume.failed')} -  
${e}`,
-              duration: 0
-            })
-          }).finally(() => {
-            this.loading = false
-          })
+          this.handleUpload()
         }).catch(e => {
           this.$notification.error({
             message: this.$t('message.upload.failed'),

Reply via email to