feat: client certificates and ability to skip ssl cert verification in desktop app (#4111)
Co-authored-by: Nivedin <53208152+nivedin@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="'CA Certificates'"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<ul
|
||||
v-if="certificates.length > 0"
|
||||
class="mx-4 border border-dividerDark rounded"
|
||||
>
|
||||
<li
|
||||
v-for="(certificate, index) in certificates"
|
||||
:key="index"
|
||||
class="flex border-dividerDark px-2 items-center justify-between"
|
||||
:class="{ 'border-t border-dividerDark': index !== 0 }"
|
||||
>
|
||||
<div class="truncate">
|
||||
{{ certificate.filename }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<HoppButtonSecondary
|
||||
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
certificate.enabled
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
"
|
||||
color="green"
|
||||
@click="toggleEntryEnabled(index)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:icon="IconTrash"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
@click="deleteEntry(index)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<HoppButtonSecondary
|
||||
class="mx-4"
|
||||
:icon="IconPlus"
|
||||
:label="'Add Certifcate File'"
|
||||
:loading="selectedFiles && selectedFiles!.length > 0"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker"
|
||||
/>
|
||||
|
||||
<p class="text-center text-secondaryLight">
|
||||
Hoppscotch supports .crt, .cer or .pem files containing one or more
|
||||
certificates.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary :label="'Save'" @click="save" />
|
||||
<HoppButtonSecondary
|
||||
:label="'Cancel'"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { useService } from "dioc/vue"
|
||||
import { ref, watch } from "vue"
|
||||
import { useFileDialog } from "@vueuse/core"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
NativeInterceptorService,
|
||||
CACertificateEntry,
|
||||
} from "@platform/interceptors/native"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||
|
||||
const certificates = ref<CACertificateEntry[]>([])
|
||||
|
||||
const {
|
||||
files: selectedFiles,
|
||||
open: openFilePicker,
|
||||
reset: resetFilePicker,
|
||||
onChange: onSelectedFilesChange,
|
||||
} = useFileDialog({
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
// When files are selected, add them to the list of certificates and reset the file list
|
||||
onSelectedFilesChange(async (files) => {
|
||||
if (files) {
|
||||
const addedCertificates: CACertificateEntry[] = []
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
addedCertificates.push({
|
||||
filename: file.name,
|
||||
enabled: true,
|
||||
certificate: data,
|
||||
})
|
||||
}
|
||||
|
||||
certificates.value.push(...addedCertificates)
|
||||
|
||||
resetFilePicker()
|
||||
}
|
||||
})
|
||||
|
||||
// When the modal is shown, clone the certificates from the service,
|
||||
// We only write to the service when the user clicks on save
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
certificates.value = cloneDeep(
|
||||
nativeInterceptorService.caCertificates.value
|
||||
)
|
||||
} else {
|
||||
resetFilePicker()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function save() {
|
||||
nativeInterceptorService.caCertificates.value = certificates.value
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
function deleteEntry(index: number) {
|
||||
certificates.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function toggleEntryEnabled(index: number) {
|
||||
certificates.value[index].enabled = !certificates.value[index].enabled
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="'Client Certificates'"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="flex flex-col space-y-4">
|
||||
<ul
|
||||
v-if="certificateMap.size > 0"
|
||||
class="mx-4 border border-dividerDark rounded"
|
||||
>
|
||||
<li
|
||||
v-for="([domain, certificate], index) in certificateMap"
|
||||
:key="domain"
|
||||
class="flex border-dividerDark px-2 items-center justify-between"
|
||||
:class="{ 'border-t border-dividerDark': index !== 0 }"
|
||||
>
|
||||
<div class="flex space-x-2">
|
||||
<div class="truncate">
|
||||
{{ domain }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-1">
|
||||
<div class="text-secondaryLight mr-2">
|
||||
{{ "PEMCert" in certificate.cert ? "PEM" : "PFX/PKCS12" }}
|
||||
</div>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
certificate.enabled
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
"
|
||||
color="green"
|
||||
@click="toggleEntryEnabled(domain)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:icon="IconTrash"
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="t('action.remove')"
|
||||
color="red"
|
||||
@click="deleteEntry(domain)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<HoppButtonSecondary
|
||||
class="mx-4"
|
||||
:icon="IconPlus"
|
||||
:label="'Add Certificate File'"
|
||||
filled
|
||||
outline
|
||||
@click="showAddModal = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary :label="'Save'" @click="save" />
|
||||
<HoppButtonSecondary
|
||||
:label="'Cancel'"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
|
||||
<ModalsNativeClientCertsAdd
|
||||
:show="showAddModal"
|
||||
:existing-domains="Array.from(certificateMap.keys())"
|
||||
@hide-modal="showAddModal = false"
|
||||
@save="saveCertificate"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
import IconTrash from "~icons/lucide/trash"
|
||||
import { ref, watch } from "vue"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { useService } from "dioc/vue"
|
||||
import {
|
||||
ClientCertificateEntry,
|
||||
NativeInterceptorService,
|
||||
} from "@platform/interceptors/native"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||
|
||||
const certificateMap = ref(new Map<string, ClientCertificateEntry>())
|
||||
|
||||
const showAddModal = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (show) {
|
||||
certificateMap.value = cloneDeep(
|
||||
nativeInterceptorService.clientCertificates.value
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function save() {
|
||||
nativeInterceptorService.clientCertificates.value = cloneDeep(
|
||||
certificateMap.value
|
||||
)
|
||||
emit("hide-modal")
|
||||
}
|
||||
|
||||
function saveCertificate(cert: ClientCertificateEntry) {
|
||||
certificateMap.value.set(cert.domain, cert)
|
||||
}
|
||||
|
||||
function toggleEntryEnabled(domain: string) {
|
||||
const certificate = certificateMap.value.get(domain)
|
||||
|
||||
if (certificate) {
|
||||
certificateMap.value.set(domain, {
|
||||
...certificate,
|
||||
enabled: !certificate.enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEntry(domain: string) {
|
||||
certificateMap.value.delete(domain)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="'Add Client Certificate'"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<HoppSmartInput
|
||||
v-model="domain"
|
||||
:autofocus="false"
|
||||
styles="flex-1"
|
||||
placeholder=" "
|
||||
:label="'Domain'"
|
||||
input-styles="input floating-input"
|
||||
/>
|
||||
|
||||
<HoppSmartTabs v-model="selectedTab">
|
||||
<HoppSmartTab :id="'pem'" :label="'PEM'">
|
||||
<div class="p-4 space-y-4">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> Certificate </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemCert?.type === 'loading'"
|
||||
:label="
|
||||
pemCert?.type === 'loaded'
|
||||
? pemCert.filename
|
||||
: 'Add Certifcate File'
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pem_cert')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> Key </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemKey?.type === 'loading'"
|
||||
:label="
|
||||
pemKey?.type === 'loaded' ? pemKey.filename : 'Add Key File'
|
||||
"
|
||||
@click="openFilePicker('pem_key')"
|
||||
filled
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab :id="'pfx'" :label="'PFX/PKCS12'">
|
||||
<div class="p-4 space-y-6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> PFX/PKCS12 File </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pfxCert?.type === 'loading'"
|
||||
:label="
|
||||
pfxCert?.type === 'loaded'
|
||||
? pfxCert.filename
|
||||
: 'Add PFX/PKCS12 File'
|
||||
"
|
||||
@click="openFilePicker('pfx_cert')"
|
||||
filled
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border border-divider rounded">
|
||||
<HoppSmartInput
|
||||
v-model="pfxPassword"
|
||||
:type="showPfxPassword ? 'text' : 'password'"
|
||||
:label="'Password'"
|
||||
input-styles="floating-input !border-0 "
|
||||
:placeholder="' '"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
showPfxPassword ? 'Hide Password' : 'Show Password'
|
||||
"
|
||||
:icon="showPfxPassword ? IconEye : IconEyeOff"
|
||||
@click="showPfxPassword = !showPfxPassword"
|
||||
/>
|
||||
</template>
|
||||
</HoppSmartInput>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
</HoppSmartTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary
|
||||
:label="'Save'"
|
||||
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
|
||||
@click="save"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="'Cancel'"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import IconPlus from "~icons/lucide/plus"
|
||||
import IconEyeOff from "~icons/lucide/eye-off"
|
||||
import IconEye from "~icons/lucide/eye"
|
||||
import IconFile from "~icons/lucide/file"
|
||||
import { ref, watch, computed } from "vue"
|
||||
import { useFileDialog } from "@vueuse/core"
|
||||
import { ClientCertificateEntry } from "../../platform/interceptors/native"
|
||||
import { useToast } from "@composables/toast"
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
existingDomains: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "save", certificate: ClientCertificateEntry): void
|
||||
}>()
|
||||
|
||||
type FileSelectorState =
|
||||
| null
|
||||
| { type: "loading" }
|
||||
| { type: "loaded"; filename: string; data: Uint8Array }
|
||||
|
||||
const domain = ref("")
|
||||
|
||||
const pemCert = ref<FileSelectorState>(null)
|
||||
const pemKey = ref<FileSelectorState>(null)
|
||||
|
||||
const pfxCert = ref<FileSelectorState>(null)
|
||||
|
||||
const pfxPassword = ref("")
|
||||
const showPfxPassword = ref(false)
|
||||
|
||||
const anyFileSelectorIsLoading = computed(
|
||||
() =>
|
||||
pemCert.value?.type === "loading" ||
|
||||
pemKey.value?.type === "loading" ||
|
||||
pfxCert.value?.type === "loading"
|
||||
)
|
||||
|
||||
const currentlyPickingFile = ref<null | "pem_cert" | "pem_key" | "pfx_cert">(
|
||||
null
|
||||
)
|
||||
|
||||
const selectedTab = ref<"pem" | "pfx">("pem")
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
|
||||
currentlyPickingFile.value = null
|
||||
|
||||
domain.value = ""
|
||||
pemCert.value = null
|
||||
pemKey.value = null
|
||||
pfxCert.value = null
|
||||
pfxPassword.value = ""
|
||||
showPfxPassword.value = false
|
||||
selectedTab.value = "pem"
|
||||
}
|
||||
)
|
||||
|
||||
const certificate = computed<ClientCertificateEntry | null>(() => {
|
||||
if (selectedTab.value === "pem") {
|
||||
if (pemCert.value?.type === "loaded" && pemKey.value?.type === "loaded") {
|
||||
return <ClientCertificateEntry>{
|
||||
domain: domain.value,
|
||||
enabled: true,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_filename: pemCert.value.filename,
|
||||
certificate_pem: pemCert.value.data,
|
||||
|
||||
key_filename: pemKey.value.filename,
|
||||
key_pem: pemKey.value.data,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (pfxCert.value?.type === "loaded") {
|
||||
return <ClientCertificateEntry>{
|
||||
domain: domain.value.trim(),
|
||||
enabled: true,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_filename: pfxCert.value.filename,
|
||||
certificate_pfx: pfxCert.value.data,
|
||||
password: pfxPassword.value,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const isValidCertificate = computed(() => {
|
||||
if (certificate.value === null) return false
|
||||
|
||||
if (props.existingDomains.includes(certificate.value.domain)) {
|
||||
toast.error("A certificate for this domain already exists")
|
||||
return false
|
||||
}
|
||||
|
||||
return ClientCertificateEntry.safeParse(certificate.value).success
|
||||
})
|
||||
|
||||
const {
|
||||
open: openFileDialog,
|
||||
reset: resetFilePicker,
|
||||
onChange: onFilePickerChange,
|
||||
} = useFileDialog({
|
||||
reset: true,
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
onFilePickerChange(async (files) => {
|
||||
if (!files) return
|
||||
|
||||
const file = files.item(0)
|
||||
|
||||
if (!file) return
|
||||
|
||||
if (currentlyPickingFile.value === "pem_cert") {
|
||||
pemCert.value = { type: "loading" }
|
||||
} else if (currentlyPickingFile.value === "pem_key") {
|
||||
pemKey.value = { type: "loading" }
|
||||
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||
pfxCert.value = { type: "loading" }
|
||||
}
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
if (currentlyPickingFile.value === "pem_cert") {
|
||||
pemCert.value = { type: "loaded", filename: file.name, data }
|
||||
} else if (currentlyPickingFile.value === "pem_key") {
|
||||
pemKey.value = { type: "loaded", filename: file.name, data }
|
||||
} else if (currentlyPickingFile.value === "pfx_cert") {
|
||||
pfxCert.value = { type: "loaded", filename: file.name, data }
|
||||
}
|
||||
|
||||
currentlyPickingFile.value = null
|
||||
|
||||
resetFilePicker()
|
||||
})
|
||||
|
||||
function openFilePicker(type: "pem_cert" | "pem_key" | "pfx_cert") {
|
||||
currentlyPickingFile.value = type
|
||||
|
||||
openFileDialog()
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (certificate.value) {
|
||||
emit("save", certificate.value)
|
||||
emit("hide-modal")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="flex items-center">
|
||||
<HoppSmartToggle
|
||||
:on="allowSSLVerification"
|
||||
@change="allowSSLVerification = !allowSSLVerification"
|
||||
/>
|
||||
Verify SSL Certificates
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-4">
|
||||
<!--
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLucideFileBadge"
|
||||
:label="'CA Certificates'"
|
||||
outline
|
||||
@click="showCACertificatesModal = true"
|
||||
/>
|
||||
-->
|
||||
<HoppButtonSecondary
|
||||
:icon="IconLucideFileKey"
|
||||
:label="'Client Certificates'"
|
||||
@click="showClientCertificatesModal = true"
|
||||
outline
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<ModalsNativeCACertificates
|
||||
:show="showCACertificatesModal"
|
||||
@hide-modal="showCACertificatesModal = false"
|
||||
/>
|
||||
-->
|
||||
<ModalsNativeClientCertificates
|
||||
:show="showClientCertificatesModal"
|
||||
@hide-modal="showClientCertificatesModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- TODO: i18n -->
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue"
|
||||
import IconLucideFileBadge from "~icons/lucide/file-badge"
|
||||
import IconLucideFileKey from "~icons/lucide/file-key"
|
||||
import { useService } from "dioc/vue"
|
||||
import { NativeInterceptorService } from "@platform/interceptors/native"
|
||||
|
||||
const nativeInterceptorService = useService(NativeInterceptorService)
|
||||
|
||||
const allowSSLVerification = nativeInterceptorService.validateCerts
|
||||
|
||||
// const showCACertificatesModal = ref(false)
|
||||
const showClientCertificatesModal = ref(false)
|
||||
</script>
|
||||
@@ -1,170 +0,0 @@
|
||||
import * as E from "fp-ts/Either"
|
||||
import {
|
||||
Interceptor,
|
||||
InterceptorError,
|
||||
RequestRunResult,
|
||||
} from "@hoppscotch/common/services/interceptor.service"
|
||||
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
|
||||
import axios, { AxiosRequestConfig, CancelToken } from "axios"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { Body, HttpVerb, ResponseType, getClient } from "@tauri-apps/api/http"
|
||||
import { Service } from "dioc"
|
||||
|
||||
export const preProcessRequest = (
|
||||
req: AxiosRequestConfig
|
||||
): AxiosRequestConfig => {
|
||||
const reqClone = cloneDeep(req)
|
||||
|
||||
// If the parameters are URLSearchParams, inject them to URL instead
|
||||
// This prevents issues of marshalling the URLSearchParams to the proxy
|
||||
if (reqClone.params instanceof URLSearchParams) {
|
||||
try {
|
||||
const url = new URL(reqClone.url ?? "")
|
||||
|
||||
for (const [key, value] of reqClone.params.entries()) {
|
||||
url.searchParams.append(key, value)
|
||||
}
|
||||
|
||||
reqClone.url = url.toString()
|
||||
} catch (e) {
|
||||
// making this a non-empty block, so we can make the linter happy.
|
||||
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
|
||||
}
|
||||
|
||||
reqClone.params = {}
|
||||
}
|
||||
|
||||
return reqClone
|
||||
}
|
||||
|
||||
async function runRequest(
|
||||
req: AxiosRequestConfig,
|
||||
cancelled: () => boolean
|
||||
): RequestRunResult["response"] {
|
||||
const timeStart = Date.now()
|
||||
|
||||
const processedReq = preProcessRequest(req)
|
||||
try {
|
||||
const client = await getClient()
|
||||
|
||||
if (cancelled()) {
|
||||
client.drop()
|
||||
return E.left("cancellation")
|
||||
}
|
||||
|
||||
let body = Body.text(processedReq.data ?? "")
|
||||
|
||||
if (processedReq.data instanceof FormData) {
|
||||
let body_data = {}
|
||||
for (const entry of processedReq.data.entries()) {
|
||||
const [name, value] = entry
|
||||
|
||||
if (value instanceof File) {
|
||||
let file_data = await value.arrayBuffer()
|
||||
|
||||
body_data[name] = {
|
||||
file: new Uint8Array(file_data),
|
||||
fileName: value.name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body = Body.form(body_data)
|
||||
}
|
||||
|
||||
const res = await client.request({
|
||||
method: processedReq.method as HttpVerb,
|
||||
url: processedReq.url ?? "",
|
||||
responseType: ResponseType.Binary,
|
||||
headers: processedReq.headers,
|
||||
body: body,
|
||||
})
|
||||
|
||||
if (cancelled()) {
|
||||
client.drop()
|
||||
return E.left("cancellation")
|
||||
}
|
||||
|
||||
res.data = new Uint8Array(res.data as number[]).buffer
|
||||
|
||||
const timeEnd = Date.now()
|
||||
|
||||
return E.right({
|
||||
...res,
|
||||
config: {
|
||||
timeData: {
|
||||
startTime: timeStart,
|
||||
endTime: timeEnd,
|
||||
},
|
||||
},
|
||||
additional: {
|
||||
multiHeaders: Object.entries(res.rawHeaders).flatMap(
|
||||
([header, values]) => values.map((value) => ({ key: header, value }))
|
||||
),
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
const timeEnd = Date.now()
|
||||
|
||||
if (axios.isAxiosError(e) && e.response) {
|
||||
return E.right({
|
||||
...e.response,
|
||||
config: {
|
||||
timeData: {
|
||||
startTime: timeStart,
|
||||
endTime: timeEnd,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else if (axios.isCancel(e)) {
|
||||
return E.left("cancellation")
|
||||
} else {
|
||||
return E.left(<InterceptorError>{
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
},
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NativeInterceptorService extends Service implements Interceptor {
|
||||
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
|
||||
|
||||
public interceptorID = "native" // TODO: i18n this
|
||||
|
||||
public name = () => "Native"
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
public supportsCookies = true
|
||||
|
||||
public cookieJarService = this.bind(CookieJarService)
|
||||
|
||||
public runRequest(req: any) {
|
||||
const processedReq = preProcessRequest(req)
|
||||
|
||||
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
||||
new URL(processedReq.url!)
|
||||
)
|
||||
|
||||
processedReq.headers["Cookie"] = relevantCookies
|
||||
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
||||
.join(";")
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const checkCancelled = () => {
|
||||
return cancelled
|
||||
}
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true
|
||||
},
|
||||
response: runRequest(processedReq, checkCancelled),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { CookieJarService } from "@hoppscotch/common/services/cookie-jar.service"
|
||||
import { Interceptor, InterceptorError, NetworkResponse, RequestRunResult } from "@hoppscotch/common/services/interceptor.service"
|
||||
import { Service } from "dioc"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { invoke } from "@tauri-apps/api/tauri"
|
||||
import * as E from "fp-ts/Either"
|
||||
import SettingsNativeInterceptor from "../../../components/settings/NativeInterceptor.vue"
|
||||
import { ref, watch } from "vue"
|
||||
import { z } from "zod"
|
||||
import { PersistenceService } from "@hoppscotch/common/services/persistence"
|
||||
import { CACertStore, ClientCertsStore, ClientCertStore, StoredClientCert } from "./persisted-data"
|
||||
|
||||
|
||||
type KeyValuePair = {
|
||||
key: string,
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormDataValue =
|
||||
| { Text: string }
|
||||
| {
|
||||
File: {
|
||||
filename: string,
|
||||
data: Uint8Array
|
||||
}
|
||||
}
|
||||
|
||||
type FormDataEntry = {
|
||||
key: string,
|
||||
value: FormDataValue
|
||||
}
|
||||
|
||||
type BodyDef =
|
||||
| { Text: string }
|
||||
| { URLEncoded: KeyValuePair[] }
|
||||
| { FormData: FormDataEntry[] }
|
||||
|
||||
type ClientCertDef =
|
||||
| {
|
||||
PEMCert: {
|
||||
certificate_pem: number[],
|
||||
key_pem: number[]
|
||||
},
|
||||
}
|
||||
| {
|
||||
PFXCert: {
|
||||
certificate_pfx: number[],
|
||||
password: string
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Figure out a way to autogen this from the interceptor definition on the Rust side
|
||||
type RequestDef = {
|
||||
req_id: number
|
||||
|
||||
method: string
|
||||
endpoint: string
|
||||
|
||||
parameters: KeyValuePair[]
|
||||
headers: KeyValuePair[]
|
||||
|
||||
body: BodyDef | null,
|
||||
|
||||
validate_certs: boolean,
|
||||
root_cert_bundle_files: number[],
|
||||
client_cert: ClientCertDef | null
|
||||
}
|
||||
|
||||
type RunRequestResponse = {
|
||||
status: number,
|
||||
status_text: string,
|
||||
headers: KeyValuePair[],
|
||||
data: number[],
|
||||
|
||||
time_start_ms: number,
|
||||
time_end_ms: number
|
||||
}
|
||||
|
||||
// HACK: To solve the AxiosRequestConfig being different between @hoppscotch/common
|
||||
// and the axios present in this package
|
||||
type AxiosRequestConfig = Parameters<Interceptor["runRequest"]>[0]
|
||||
|
||||
export const preProcessRequest = (
|
||||
req: AxiosRequestConfig
|
||||
): AxiosRequestConfig => {
|
||||
const reqClone = cloneDeep(req)
|
||||
|
||||
// If the parameters are URLSearchParams, inject them to URL instead
|
||||
// This prevents issues of marshalling the URLSearchParams to the proxy
|
||||
if (reqClone.params instanceof URLSearchParams) {
|
||||
try {
|
||||
const url = new URL(reqClone.url ?? "")
|
||||
|
||||
for (const [key, value] of reqClone.params.entries()) {
|
||||
url.searchParams.append(key, value)
|
||||
}
|
||||
|
||||
reqClone.url = url.toString()
|
||||
} catch (e) {
|
||||
// making this a non-empty block, so we can make the linter happy.
|
||||
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
|
||||
}
|
||||
|
||||
reqClone.params = {}
|
||||
}
|
||||
|
||||
return reqClone
|
||||
}
|
||||
|
||||
async function processBody(axiosReq: AxiosRequestConfig): Promise<BodyDef | null> {
|
||||
if (!axiosReq.data) return null
|
||||
|
||||
if (typeof axiosReq.data === "string") {
|
||||
return { Text: axiosReq.data }
|
||||
}
|
||||
|
||||
if (axiosReq.data instanceof FormData) {
|
||||
const entries: FormDataEntry[] = []
|
||||
|
||||
for (const [key, value] of axiosReq.data.entries()) {
|
||||
if (typeof value === "string") {
|
||||
entries.push({
|
||||
key,
|
||||
value: { Text: value }
|
||||
})
|
||||
} else {
|
||||
entries.push({
|
||||
key,
|
||||
value: {
|
||||
File: {
|
||||
filename: value.name,
|
||||
data: new Uint8Array(await value.arrayBuffer())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { FormData: entries }
|
||||
}
|
||||
|
||||
throw new Error("Native Process Body: Unhandled Axios Request Configuration")
|
||||
}
|
||||
|
||||
function getURLDomain(url: string): string | null {
|
||||
try {
|
||||
return new URL(url).host
|
||||
} catch (_) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function convertClientCertToDefCert(cert: ClientCertificateEntry): ClientCertDef {
|
||||
if ("PEMCert" in cert.cert) {
|
||||
return {
|
||||
PEMCert: {
|
||||
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
|
||||
key_pem: Array.from(cert.cert.PEMCert.key_pem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
PFXCert: {
|
||||
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
|
||||
password: cert.cert.PFXCert.password
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function convertToRequestDef(
|
||||
axiosReq: AxiosRequestConfig,
|
||||
reqID: number,
|
||||
caCertificates: CACertificateEntry[],
|
||||
clientCertificates: Map<string, ClientCertificateEntry>,
|
||||
validateCerts: boolean
|
||||
): Promise<RequestDef> {
|
||||
const clientCertDomain = getURLDomain(axiosReq.url!)
|
||||
|
||||
const clientCert = clientCertDomain ? clientCertificates.get(clientCertDomain) : null
|
||||
|
||||
return {
|
||||
req_id: reqID,
|
||||
method: axiosReq.method ?? "GET",
|
||||
endpoint: axiosReq.url ?? "",
|
||||
headers: Object.entries(axiosReq.headers ?? {})
|
||||
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||
parameters: Object.entries(axiosReq.params as Record<string, string> ?? {})
|
||||
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||
body: await processBody(axiosReq),
|
||||
root_cert_bundle_files: caCertificates.map((cert) => Array.from(cert.certificate)),
|
||||
validate_certs: validateCerts,
|
||||
client_cert: clientCert ? convertClientCertToDefCert(clientCert) : null
|
||||
}
|
||||
}
|
||||
|
||||
export const CACertificateEntry = z.object({
|
||||
filename: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
certificate: z.instanceof(Uint8Array)
|
||||
})
|
||||
|
||||
export type CACertificateEntry = z.infer<typeof CACertificateEntry>
|
||||
|
||||
export const ClientCertificateEntry = z.object({
|
||||
enabled: z.boolean(),
|
||||
domain: z.string().trim().min(1),
|
||||
cert: z.union([
|
||||
z.object({
|
||||
PEMCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pem: z.instanceof(Uint8Array),
|
||||
|
||||
key_filename: z.string().min(1),
|
||||
key_pem: z.instanceof(Uint8Array),
|
||||
})
|
||||
}),
|
||||
z.object({
|
||||
PFXCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pfx: z.instanceof(Uint8Array),
|
||||
|
||||
password: z.string()
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
export type ClientCertificateEntry = z.infer<typeof ClientCertificateEntry>
|
||||
|
||||
const CA_STORE_PERSIST_KEY = "native_interceptor_ca_store"
|
||||
const CLIENT_CERTS_PERSIST_KEY = "native_interceptor_client_certs_store"
|
||||
const VALIDATE_SSL_KEY = "native_interceptor_validate_ssl"
|
||||
|
||||
export class NativeInterceptorService extends Service implements Interceptor {
|
||||
public static readonly ID = "NATIVE_INTERCEPTOR_SERVICE"
|
||||
|
||||
public interceptorID = "native"
|
||||
|
||||
public name = () => "Native"
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
public supportsCookies = true
|
||||
|
||||
private cookieJarService = this.bind(CookieJarService)
|
||||
private persistenceService: PersistenceService = this.bind(PersistenceService)
|
||||
|
||||
private reqIDTicker = 0
|
||||
|
||||
public settingsPageEntry = {
|
||||
entryTitle: () => "Native", // TODO: i18n this
|
||||
component: SettingsNativeInterceptor
|
||||
}
|
||||
|
||||
public caCertificates = ref<CACertificateEntry[]>([])
|
||||
|
||||
public clientCertificates = ref<Map<string, ClientCertificateEntry>>(new Map())
|
||||
public validateCerts = ref(true)
|
||||
|
||||
override onServiceInit() {
|
||||
// Load SSL Validation
|
||||
const persistedValidateSSL: unknown = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(VALIDATE_SSL_KEY) ?? "null"
|
||||
)
|
||||
|
||||
if (typeof persistedValidateSSL === "boolean") {
|
||||
this.validateCerts.value = persistedValidateSSL
|
||||
}
|
||||
|
||||
watch(this.validateCerts, () => {
|
||||
this.persistenceService.setLocalConfig(VALIDATE_SSL_KEY, JSON.stringify(this.validateCerts.value))
|
||||
})
|
||||
|
||||
// Load and setup writes for CA Store
|
||||
const persistedCAStoreData = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(CA_STORE_PERSIST_KEY) ?? "null"
|
||||
)
|
||||
|
||||
const caStoreDataParseResult = CACertStore.safeParse(persistedCAStoreData)
|
||||
|
||||
if (caStoreDataParseResult.type === "ok") {
|
||||
this.caCertificates.value = caStoreDataParseResult.value.certs
|
||||
.map((entry) => ({ ...entry, certificate: new Uint8Array(entry.certificate) }))
|
||||
}
|
||||
|
||||
watch(this.caCertificates, (certs) => {
|
||||
const storableValue: CACertStore = {
|
||||
v: 1,
|
||||
certs: certs
|
||||
.map((el) => ({ ...el, certificate: Array.from(el.certificate) }))
|
||||
}
|
||||
|
||||
this.persistenceService.setLocalConfig(CA_STORE_PERSIST_KEY, JSON.stringify(storableValue))
|
||||
})
|
||||
|
||||
|
||||
// Load and setup writes for Client Certs Store
|
||||
const persistedClientCertStoreData = JSON.parse(
|
||||
this.persistenceService.getLocalConfig(CLIENT_CERTS_PERSIST_KEY) ?? "null"
|
||||
)
|
||||
|
||||
const clientCertStoreDataParseResult = ClientCertsStore.safeParse(persistedClientCertStoreData)
|
||||
|
||||
if (clientCertStoreDataParseResult.type === "ok") {
|
||||
this.clientCertificates.value = new Map(
|
||||
Object.entries(
|
||||
clientCertStoreDataParseResult.value.clientCerts
|
||||
)
|
||||
.map(([domain, cert]) => {
|
||||
if ("PFXCert" in cert.cert) {
|
||||
const newCert = <ClientCertificateEntry>{
|
||||
...cert,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_pfx: new Uint8Array(cert.cert.PFXCert.certificate_pfx),
|
||||
certificate_filename: cert.cert.PFXCert.certificate_filename,
|
||||
|
||||
password: cert.cert.PFXCert.password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
} else {
|
||||
const newCert = <ClientCertificateEntry>{
|
||||
...cert,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_pem: new Uint8Array(cert.cert.PEMCert.certificate_pem),
|
||||
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||
|
||||
key_pem: new Uint8Array(cert.cert.PEMCert.key_pem),
|
||||
key_filename: cert.cert.PEMCert.key_filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
watch(this.clientCertificates, (certs) => {
|
||||
const storableValue: ClientCertStore = {
|
||||
v: 1,
|
||||
clientCerts: Object.fromEntries(
|
||||
Array.from(certs.entries())
|
||||
.map(([domain, cert]) => {
|
||||
if ("PFXCert" in cert.cert) {
|
||||
const newCert = <StoredClientCert>{
|
||||
...cert,
|
||||
cert: {
|
||||
PFXCert: {
|
||||
certificate_pfx: Array.from(cert.cert.PFXCert.certificate_pfx),
|
||||
certificate_filename: cert.cert.PFXCert.certificate_filename,
|
||||
|
||||
password: cert.cert.PFXCert.password
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
} else {
|
||||
const newCert = <StoredClientCert>{
|
||||
...cert,
|
||||
cert: {
|
||||
PEMCert: {
|
||||
certificate_pem: Array.from(cert.cert.PEMCert.certificate_pem),
|
||||
certificate_filename: cert.cert.PEMCert.certificate_filename,
|
||||
|
||||
key_pem: Array.from(cert.cert.PEMCert.key_pem),
|
||||
key_filename: cert.cert.PEMCert.key_filename
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [domain, newCert]
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
this.persistenceService.setLocalConfig(CLIENT_CERTS_PERSIST_KEY, JSON.stringify(storableValue))
|
||||
})
|
||||
}
|
||||
|
||||
public runRequest(req: AxiosRequestConfig): RequestRunResult<InterceptorError> {
|
||||
const processedReq = preProcessRequest(req)
|
||||
|
||||
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
||||
new URL(processedReq.url!)
|
||||
)
|
||||
|
||||
processedReq.headers["Cookie"] = relevantCookies
|
||||
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
||||
.join(";")
|
||||
|
||||
const reqID = this.reqIDTicker++;
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
invoke("plugin:hopp_native_interceptor|cancel_request", { reqId: reqID });
|
||||
},
|
||||
response: (async () => {
|
||||
const requestDef = await convertToRequestDef(
|
||||
processedReq,
|
||||
reqID,
|
||||
this.caCertificates.value,
|
||||
this.clientCertificates.value,
|
||||
this.validateCerts.value
|
||||
)
|
||||
|
||||
try {
|
||||
console.log(requestDef)
|
||||
|
||||
const response: RunRequestResponse = await invoke(
|
||||
"plugin:hopp_native_interceptor|run_request",
|
||||
{ req: requestDef }
|
||||
)
|
||||
|
||||
return E.right({
|
||||
headers: Object.fromEntries(
|
||||
response.headers.map(({ key, value }) => [key, value])
|
||||
),
|
||||
status: response.status,
|
||||
statusText: response.status_text,
|
||||
data: new Uint8Array(response.data).buffer,
|
||||
config: {
|
||||
timeData: {
|
||||
startTime: response.time_start_ms,
|
||||
endTime: response.time_end_ms
|
||||
}
|
||||
},
|
||||
additional: {
|
||||
multiHeaders: response.headers
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
|
||||
if (typeof e === "object" && (e as any)["RequestCancelled"]) {
|
||||
return E.left("cancellation" as const)
|
||||
}
|
||||
|
||||
// TODO: More in-depth error messages
|
||||
return E.left(<InterceptorError>{
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
}
|
||||
})
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { z } from "zod"
|
||||
import { defineVersion, createVersionedEntity, InferredEntity } from "verzod"
|
||||
|
||||
const Uint8 = z.number()
|
||||
.int()
|
||||
.gte(0)
|
||||
.lte(255);
|
||||
|
||||
export const StoredCACert = z.object({
|
||||
filename: z.string().min(1),
|
||||
enabled: z.boolean(),
|
||||
certificate: z.array(Uint8)
|
||||
})
|
||||
|
||||
const caCertStore_v1 = defineVersion({
|
||||
initial: true,
|
||||
schema: z.object({
|
||||
v: z.literal(1),
|
||||
certs: z.array(StoredCACert)
|
||||
})
|
||||
})
|
||||
|
||||
export const CACertStore = createVersionedEntity({
|
||||
latestVersion: 1,
|
||||
versionMap: {
|
||||
1: caCertStore_v1
|
||||
},
|
||||
getVersion(data) {
|
||||
const result = caCertStore_v1.schema.safeParse(data)
|
||||
|
||||
return result.success ? result.data.v : null
|
||||
}
|
||||
})
|
||||
|
||||
export type CACertStore = InferredEntity<typeof CACertStore>
|
||||
|
||||
|
||||
|
||||
export const StoredClientCert = z.object({
|
||||
enabled: z.boolean(),
|
||||
domain: z.string().trim().min(1),
|
||||
cert: z.union([
|
||||
z.object({
|
||||
PEMCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pem: z.array(Uint8),
|
||||
|
||||
key_filename: z.string().min(1),
|
||||
key_pem: z.array(Uint8)
|
||||
})
|
||||
}),
|
||||
z.object({
|
||||
PFXCert: z.object({
|
||||
certificate_filename: z.string().min(1),
|
||||
certificate_pfx: z.array(Uint8),
|
||||
|
||||
password: z.string()
|
||||
})
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
export type StoredClientCert = z.infer<typeof StoredClientCert>
|
||||
|
||||
const clientCertsStore_v1 = defineVersion({
|
||||
initial: true,
|
||||
schema: z.object({
|
||||
v: z.literal(1),
|
||||
clientCerts: z.record(StoredClientCert)
|
||||
})
|
||||
})
|
||||
|
||||
export const ClientCertsStore = createVersionedEntity({
|
||||
latestVersion: 1,
|
||||
versionMap: {
|
||||
1: clientCertsStore_v1
|
||||
},
|
||||
getVersion(data) {
|
||||
const result = clientCertsStore_v1.schema.safeParse(data)
|
||||
|
||||
return result.success ? result.data.v : null
|
||||
}
|
||||
})
|
||||
|
||||
export type ClientCertStore = InferredEntity<typeof ClientCertsStore>
|
||||
Reference in New Issue
Block a user