chore(common): deprecate legacy interceptor system (#5830)
This commit is contained in:
parent
1de672b8bd
commit
2e989cf242
30 changed files with 23 additions and 3186 deletions
|
|
@ -268,13 +268,6 @@ declare module 'vue' {
|
|||
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
|
||||
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
|
||||
InstanceSwitcher: typeof import('./components/instance/Switcher.vue')['default']
|
||||
InterceptorsAgentModalNativeCACertificates: typeof import('./components/interceptors/agent/ModalNativeCACertificates.vue')['default']
|
||||
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
|
||||
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
|
||||
InterceptorsAgentRegistrationModal: typeof import('./components/interceptors/agent/RegistrationModal.vue')['default']
|
||||
InterceptorsAgentRootExt: typeof import('./components/interceptors/agent/RootExt.vue')['default']
|
||||
InterceptorsErrorPlaceholder: typeof import('./components/interceptors/ErrorPlaceholder.vue')['default']
|
||||
InterceptorsExtensionSubtitle: typeof import('./components/interceptors/ExtensionSubtitle.vue')['default']
|
||||
LensesHeadersRenderer: typeof import('./components/lenses/HeadersRenderer.vue')['default']
|
||||
LensesHeadersRendererEntry: typeof import('./components/lenses/HeadersRendererEntry.vue')['default']
|
||||
LensesRenderersAudioLensRenderer: typeof import('./components/lenses/renderers/AudioLensRenderer.vue')['default']
|
||||
|
|
@ -305,6 +298,7 @@ declare module 'vue' {
|
|||
SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default']
|
||||
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
|
||||
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
|
||||
SettingsInterceptorErrorPlaceholder: typeof import('./components/settings/InterceptorErrorPlaceholder.vue')['default']
|
||||
SettingsNative: typeof import('./components/settings/Native.vue')['default']
|
||||
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
|
||||
Share: typeof import('./components/share/index.vue')['default']
|
||||
|
|
|
|||
|
|
@ -104,8 +104,6 @@ import {
|
|||
} from "~/services/spotlight/searchers/environment.searcher"
|
||||
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
|
||||
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
||||
// NOTE: Old interceptors
|
||||
// import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
|
||||
import { KernelInterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/kernel-interceptor.searcher"
|
||||
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
|
||||
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
|
||||
|
|
@ -146,8 +144,6 @@ useService(EnvironmentsSpotlightSearcherService)
|
|||
useService(SwitchEnvSpotlightSearcherService)
|
||||
useService(WorkspaceSpotlightSearcherService)
|
||||
useService(SwitchWorkspaceSpotlightSearcherService)
|
||||
// NOTE: Old interceptors
|
||||
// useService(InterceptorSpotlightSearcherService)
|
||||
useService(KernelInterceptorSpotlightSearcherService)
|
||||
useService(TeamsSpotlightSearcherService)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="
|
||||
interceptorSelection === extensionService.interceptorID &&
|
||||
extensionService.extensionStatus.value !== 'available'
|
||||
"
|
||||
class="flex space-x-2"
|
||||
>
|
||||
<HoppButtonSecondary
|
||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||
blank
|
||||
:icon="IconChrome"
|
||||
label="Chrome"
|
||||
outline
|
||||
class="!flex-1"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||
blank
|
||||
:icon="IconFirefox"
|
||||
label="Firefox"
|
||||
outline
|
||||
class="!flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconChrome from "~icons/brands/chrome"
|
||||
import IconFirefox from "~icons/brands/firefox"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { useService } from "dioc/vue"
|
||||
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
|
||||
|
||||
const interceptorService = useService(InterceptorService)
|
||||
const extensionService = useService(ExtensionInterceptorService)
|
||||
|
||||
const interceptorSelection = interceptorService.currentInterceptorID
|
||||
</script>
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('agent.ca_certs')"
|
||||
@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
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||
:title="
|
||||
certificate.enabled
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
"
|
||||
color="green"
|
||||
@click="toggleEntryEnabled(index)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
:title="t('action.remove')"
|
||||
@click="deleteEntry(index)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<HoppButtonSecondary
|
||||
class="mx-4"
|
||||
:icon="IconPlus"
|
||||
:label="t('agent.add_cert_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="t('action.save')" @click="save" />
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.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 { useI18n } from "@composables/i18n"
|
||||
import {
|
||||
CACertificateEntry,
|
||||
AgentInterceptorService,
|
||||
} from "~/platform/std/interceptors/agent"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { hasValidExtension } from "~/helpers/utils/file-extension"
|
||||
|
||||
const t = useI18n()
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const nativeInterceptorService = useService(AgentInterceptorService)
|
||||
|
||||
const certificates = ref<CACertificateEntry[]>([])
|
||||
|
||||
const {
|
||||
files: selectedFiles,
|
||||
open: openFilePicker,
|
||||
reset: resetFilePicker,
|
||||
onChange: onSelectedFilesChange,
|
||||
} = useFileDialog({
|
||||
multiple: true,
|
||||
})
|
||||
|
||||
const ALLOWED_EXTENSIONS = [".crt", ".cer", ".pem"]
|
||||
|
||||
function isValidCertType(filename: string): boolean {
|
||||
return hasValidExtension(filename, ALLOWED_EXTENSIONS)
|
||||
}
|
||||
|
||||
// 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]
|
||||
|
||||
if (!isValidCertType(file.name)) {
|
||||
toast.error(t("error.invalid_file_type", { filename: file.name }))
|
||||
continue
|
||||
}
|
||||
|
||||
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>
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('agent.client_certs')"
|
||||
@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
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="certificate.enabled ? IconCheckCircle : IconCircle"
|
||||
:title="
|
||||
certificate.enabled
|
||||
? t('action.turn_off')
|
||||
: t('action.turn_on')
|
||||
"
|
||||
color="green"
|
||||
@click="toggleEntryEnabled(domain)"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:icon="IconTrash"
|
||||
:title="t('action.remove')"
|
||||
color="red"
|
||||
@click="deleteEntry(domain)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<HoppButtonSecondary
|
||||
class="mx-4"
|
||||
:icon="IconPlus"
|
||||
:label="t('agent.add_cert_file')"
|
||||
filled
|
||||
outline
|
||||
@click="showAddModal = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex space-x-2">
|
||||
<HoppButtonPrimary :label="t('action.save')" @click="save" />
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
filled
|
||||
outline
|
||||
@click="emit('hide-modal')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
|
||||
<InterceptorsAgentModalNativeClientCertsAdd
|
||||
: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 { cloneDeep } from "lodash-es"
|
||||
import {
|
||||
ClientCertificateEntry,
|
||||
AgentInterceptorService,
|
||||
} from "~/platform/std/interceptors/agent"
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
}>()
|
||||
|
||||
const t = useI18n()
|
||||
|
||||
const nativeInterceptorService = useService(AgentInterceptorService)
|
||||
|
||||
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>
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
<template>
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
:title="t('agent.add_client_cert')"
|
||||
@close="emit('hide-modal')"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<HoppSmartInput
|
||||
v-model="domain"
|
||||
:autofocus="false"
|
||||
styles="flex-1"
|
||||
placeholder=" "
|
||||
:label="t('agent.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> {{ t("agent.cert") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemCert?.type === 'loading'"
|
||||
:label="
|
||||
pemCert?.type === 'loaded'
|
||||
? pemCert.filename
|
||||
: t('agent.add_cert_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pem_cert')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> {{ t("agent.key") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pemKey?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pemKey?.type === 'loading'"
|
||||
:label="
|
||||
pemKey?.type === 'loaded'
|
||||
? pemKey.filename
|
||||
: t('agent.add_key_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pem_key')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HoppSmartTab>
|
||||
|
||||
<HoppSmartTab :id="'pfx'" :label="t('agent.pfx_or_pkcs')">
|
||||
<div class="p-4 space-y-6">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<label> {{ t("agent.pfx_or_pkcs_file") }} </label>
|
||||
<HoppButtonSecondary
|
||||
:icon="pfxCert?.type === 'loaded' ? IconFile : IconPlus"
|
||||
:loading="pfxCert?.type === 'loading'"
|
||||
:label="
|
||||
pfxCert?.type === 'loaded'
|
||||
? pfxCert.filename
|
||||
: t('agent.add_pfx_or_pkcs_file')
|
||||
"
|
||||
filled
|
||||
outline
|
||||
@click="openFilePicker('pfx_cert')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="border border-divider rounded">
|
||||
<HoppSmartInput
|
||||
v-model="pfxPassword"
|
||||
:type="showPfxPassword ? 'text' : 'password'"
|
||||
:label="t('authorization.password')"
|
||||
input-styles="floating-input !border-0 "
|
||||
:placeholder="' '"
|
||||
>
|
||||
<template #button>
|
||||
<HoppButtonSecondary
|
||||
v-tippy="{ theme: 'tooltip' }"
|
||||
:title="
|
||||
showPfxPassword
|
||||
? t('hide.password')
|
||||
: t('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="t('action.save')"
|
||||
:disabled="!isValidCertificate || anyFileSelectorIsLoading"
|
||||
@click="save"
|
||||
/>
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.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 { useToast } from "@composables/toast"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { ClientCertificateEntry } from "~/platform/std/interceptors/agent"
|
||||
|
||||
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 t = useI18n()
|
||||
|
||||
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>
|
||||
|
|
@ -1,146 +0,0 @@
|
|||
<template>
|
||||
<!-- TODO: i18n -->
|
||||
<HoppSmartModal
|
||||
v-if="show"
|
||||
dialog
|
||||
styles="sm:max-w-md"
|
||||
:title="modalTitle"
|
||||
@close="hideModal"
|
||||
>
|
||||
<template #body>
|
||||
<div class="space-y-4">
|
||||
<p v-if="status === 'agent_not_running'" class="text-secondaryLight">
|
||||
{{ t("agent.not_running") }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="status === 'registration_required'">
|
||||
<p
|
||||
v-if="registrationStatus === 'initial'"
|
||||
class="text-secondaryLight"
|
||||
>
|
||||
{{ t("agent.registration_instruction") }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="registrationStatus === 'otp_required'">
|
||||
<p class="text-secondaryLight">
|
||||
{{ t("agent.enter_otp_instruction") }}
|
||||
</p>
|
||||
|
||||
<HoppSmartInput
|
||||
v-model="userEnteredOTP"
|
||||
placeholder=" "
|
||||
:label="t('agent.otp_label')"
|
||||
input-styles="input floating-input"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="isRegistrationLoading"
|
||||
class="flex items-center space-x-2"
|
||||
>
|
||||
<HoppSmartSpinner />
|
||||
|
||||
<p class="text-secondaryLight">{{ t("agent.processing") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-start flex-1">
|
||||
<HoppButtonPrimary
|
||||
:label="primaryButtonLabel"
|
||||
:loading="isRegistrationLoading"
|
||||
@click="primaryActionHandler"
|
||||
/>
|
||||
|
||||
<HoppButtonSecondary
|
||||
:label="t('action.cancel')"
|
||||
class="ml-2"
|
||||
filled
|
||||
outline
|
||||
@click="hideModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</HoppSmartModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue"
|
||||
|
||||
import { useI18n } from "~/composables/i18n"
|
||||
|
||||
const t = useI18n()
|
||||
const userEnteredOTP = ref("")
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
status: "agent_not_running" | "registration_required" | "hidden"
|
||||
registrationStatus: "initial" | "otp_required" | "loading"
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "hide-modal"): void
|
||||
(e: "register"): void
|
||||
(e: "verify", otp: string): void
|
||||
(e: "retry-connection"): void
|
||||
}>()
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
switch (props.status) {
|
||||
case "agent_not_running":
|
||||
return t("agent.not_running_title")
|
||||
case "registration_required":
|
||||
return t("agent.registration_title")
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
})
|
||||
|
||||
const isRegistrationLoading = computed(
|
||||
() => props.registrationStatus === "loading"
|
||||
)
|
||||
|
||||
const primaryButtonLabel = computed(() => {
|
||||
if (isRegistrationLoading.value) {
|
||||
return t("state.loading")
|
||||
}
|
||||
|
||||
if (props.status === "agent_not_running") {
|
||||
return t("action.retry")
|
||||
}
|
||||
|
||||
if (props.status === "registration_required") {
|
||||
if (props.registrationStatus === "initial") {
|
||||
return t("action.register")
|
||||
}
|
||||
|
||||
if (props.registrationStatus === "otp_required") {
|
||||
return t("action.verify")
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
})
|
||||
|
||||
const primaryActionHandler = () => {
|
||||
if (props.status === "agent_not_running") {
|
||||
return emit("retry-connection")
|
||||
}
|
||||
|
||||
if (props.status === "registration_required") {
|
||||
if (props.registrationStatus === "initial") {
|
||||
return emit("register")
|
||||
}
|
||||
|
||||
if (props.registrationStatus === "otp_required") {
|
||||
return emit("verify", userEnteredOTP.value)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hideModal = () => emit("hide-modal")
|
||||
</script>
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
<template>
|
||||
<InterceptorsAgentRegistrationModal
|
||||
:show="showModal"
|
||||
:status="modalStatus"
|
||||
:registration-status="registrationStatus"
|
||||
@hide-modal="hideModal"
|
||||
@register="register"
|
||||
@verify="verifyOTP"
|
||||
@retry-connection="checkAgentStatus(true)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useService } from "dioc/vue"
|
||||
import { AgentInterceptorService } from "~/platform/std/interceptors/agent"
|
||||
import { ref, onMounted, computed, watch } from "vue"
|
||||
import { useToast } from "@composables/toast"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { defineActionHandler } from "~/helpers/actions"
|
||||
|
||||
// TODO: Move as much as logic as possible to AgentInterceptorService
|
||||
|
||||
const interceptorService = useService(InterceptorService) // TODO: Try to remove dependency to InterceptorService
|
||||
const agentService = useService(AgentInterceptorService)
|
||||
const showModal = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const modalStatus = computed(() => {
|
||||
if (!agentService.isAgentRunning.value) return "agent_not_running"
|
||||
if (!agentService.isAuthKeyPresent()) return "registration_required"
|
||||
return "hidden"
|
||||
})
|
||||
|
||||
const registrationStatus = ref<"initial" | "otp_required" | "loading">(
|
||||
"initial"
|
||||
)
|
||||
|
||||
async function checkAgentStatus(isRetry = false) {
|
||||
if (
|
||||
interceptorService.currentInterceptor.value?.interceptorID ===
|
||||
agentService.interceptorID
|
||||
) {
|
||||
await agentService.checkAgentStatus()
|
||||
updateModalVisibility()
|
||||
|
||||
if (isRetry && !agentService.isAgentRunning.value) {
|
||||
toast.error("Agent is not running.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(interceptorService.currentInterceptor, () => {
|
||||
checkAgentStatus()
|
||||
})
|
||||
|
||||
function updateModalVisibility() {
|
||||
showModal.value = modalStatus.value !== "hidden"
|
||||
if (showModal.value && modalStatus.value === "registration_required") {
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await checkAgentStatus()
|
||||
})
|
||||
|
||||
function hideModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function register() {
|
||||
registrationStatus.value = "loading"
|
||||
try {
|
||||
await agentService.initiateRegistration()
|
||||
registrationStatus.value = "otp_required"
|
||||
} catch (_error) {
|
||||
toast.error("Failed to initiate registration. Please try again.")
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyOTP(otp: string) {
|
||||
registrationStatus.value = "loading"
|
||||
try {
|
||||
await agentService.verifyRegistration(otp)
|
||||
toast.success("Registration successful!")
|
||||
hideModal()
|
||||
} catch (_error) {
|
||||
toast.error("Failed to verify OTP. Please try again.")
|
||||
registrationStatus.value = "otp_required"
|
||||
}
|
||||
}
|
||||
|
||||
defineActionHandler("agent.open-registration-modal", () => {
|
||||
if (!showModal.value) {
|
||||
showModal.value = true
|
||||
registrationStatus.value = "initial"
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -59,36 +59,31 @@ import IconChrome from "~icons/brands/chrome"
|
|||
import IconFirefox from "~icons/brands/firefox"
|
||||
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||
import { useI18n } from "@composables/i18n"
|
||||
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
|
||||
import { useColorMode } from "@composables/theming"
|
||||
import { useService } from "dioc/vue"
|
||||
import { computed } from "vue"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||
import { ExtensionKernelInterceptorService } from "~/platform/std/kernel-interceptors/extension"
|
||||
import { platform } from "~/platform"
|
||||
import { useColorMode } from "~/composables/theming"
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const t = useI18n()
|
||||
|
||||
const interceptorService = useService(InterceptorService)
|
||||
const extensionService = useService(ExtensionInterceptorService)
|
||||
const kernelInterceptorService = useService(KernelInterceptorService)
|
||||
const extensionService = useService(ExtensionKernelInterceptorService)
|
||||
|
||||
const hasChromeExtInstalled = extensionService.chromeExtensionInstalled
|
||||
const hasFirefoxExtInstalled = extensionService.firefoxExtensionInstalled
|
||||
|
||||
const extensionEnabled = computed({
|
||||
get() {
|
||||
return (
|
||||
interceptorService.currentInterceptorID.value ===
|
||||
extensionService.interceptorID
|
||||
)
|
||||
return kernelInterceptorService.current.value?.id === extensionService.id
|
||||
},
|
||||
set(active) {
|
||||
if (active) {
|
||||
interceptorService.currentInterceptorID.value =
|
||||
extensionService.interceptorID
|
||||
kernelInterceptorService.setActive(extensionService.id)
|
||||
} else {
|
||||
interceptorService.currentInterceptorID.value =
|
||||
platform.interceptors.default
|
||||
kernelInterceptorService.setActive(platform.kernelInterceptors.default)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -123,7 +123,6 @@ export type HoppAction =
|
|||
| "share.request" // Share REST request
|
||||
| "tab.duplicate-tab" // Duplicate REST request
|
||||
| "gql.request.open" // Open GraphQL request
|
||||
| "agent.open-registration-modal" // Open Hoppscotch Agent registration modal
|
||||
| "app.quit" // Quit app
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { HoppModule } from "."
|
||||
import { getService } from "./dioc"
|
||||
import { platform } from "~/platform"
|
||||
import { watch } from "vue"
|
||||
import { applySetting } from "~/newstore/settings"
|
||||
import { useSettingStatic } from "~/composables/settings"
|
||||
|
||||
export default <HoppModule>{
|
||||
deprecated: true,
|
||||
onVueAppInit() {
|
||||
const interceptorService = getService(InterceptorService)
|
||||
|
||||
for (const interceptorDef of platform.interceptors.interceptors) {
|
||||
if (interceptorDef.type === "standalone") {
|
||||
interceptorService.registerInterceptor(interceptorDef.interceptor)
|
||||
} else {
|
||||
const service = getService(interceptorDef.service)
|
||||
|
||||
interceptorService.registerInterceptor(service)
|
||||
}
|
||||
}
|
||||
|
||||
interceptorService.currentInterceptorID.value =
|
||||
platform.interceptors.default
|
||||
|
||||
watch(interceptorService.currentInterceptorID, (id) => {
|
||||
applySetting(
|
||||
"CURRENT_INTERCEPTOR_ID",
|
||||
id ?? platform.interceptors.default
|
||||
)
|
||||
})
|
||||
|
||||
const [setting] = useSettingStatic("CURRENT_INTERCEPTOR_ID")
|
||||
|
||||
watch(
|
||||
setting,
|
||||
() => {
|
||||
interceptorService.currentInterceptorID.value =
|
||||
setting.value ?? platform.interceptors.default
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
},
|
||||
}
|
||||
|
|
@ -54,7 +54,6 @@ export type SettingsDef = {
|
|||
multipartFormdata: boolean
|
||||
}
|
||||
|
||||
CURRENT_INTERCEPTOR_ID: string
|
||||
CURRENT_KERNEL_INTERCEPTOR_ID: string
|
||||
|
||||
URL_EXCLUDES: {
|
||||
|
|
@ -121,7 +120,6 @@ export const getDefaultSettings = (): SettingsDef => {
|
|||
},
|
||||
|
||||
// Set empty because interceptor module will set the default value
|
||||
CURRENT_INTERCEPTOR_ID: "",
|
||||
CURRENT_KERNEL_INTERCEPTOR_ID: "",
|
||||
|
||||
// TODO: Interceptor related settings should move under the interceptor systems
|
||||
|
|
@ -317,19 +315,22 @@ export function performSettingsDataMigrations(data: any): SettingsDef {
|
|||
const source = cloneDeep(data)
|
||||
|
||||
if (source["EXTENSIONS_ENABLED"]) {
|
||||
const result = JSON.parse(source["EXTENSIONS_ENABLED"])
|
||||
|
||||
if (result) source["CURRENT_INTERCEPTOR_ID"] = "extension"
|
||||
delete source["EXTENSIONS_ENABLED"]
|
||||
}
|
||||
|
||||
if (source["PROXY_ENABLED"]) {
|
||||
const result = JSON.parse(source["PROXY_ENABLED"])
|
||||
|
||||
if (result) source["CURRENT_INTERCEPTOR_ID"] = "proxy"
|
||||
delete source["PROXY_ENABLED"]
|
||||
}
|
||||
|
||||
// Remove legacy interceptor ID if present,
|
||||
// NOTE: These are not for `kernel` interceptors,
|
||||
// those now don't participate in global settings,
|
||||
// rather each has its own `store.ts` that can be
|
||||
// controlled independently.
|
||||
if (has(source, "CURRENT_INTERCEPTOR_ID")) {
|
||||
delete source["CURRENT_INTERCEPTOR_ID"]
|
||||
}
|
||||
|
||||
const final = defaultsDeep(source, getDefaultSettings())
|
||||
|
||||
return final
|
||||
|
|
|
|||
|
|
@ -225,33 +225,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTE: Old interceptors
|
||||
<div class="md:grid md:grid-cols-3 md:gap-4">
|
||||
<div class="p-8 md:col-span-1">
|
||||
<h3 class="heading">
|
||||
{{ t("settings.interceptor") }}
|
||||
</h3>
|
||||
<p class="my-1 text-secondaryLight">
|
||||
{{ t("settings.interceptor_description") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-8 p-8 md:col-span-2">
|
||||
<section class="flex flex-col space-y-2">
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ t("settings.interceptor") }}
|
||||
</h4>
|
||||
<AppInterceptor :is-tooltip-component="false" />
|
||||
</section>
|
||||
<section v-for="[id, settings] in interceptorsWithSettings" :key="id">
|
||||
<h4 class="font-semibold text-secondaryDark">
|
||||
{{ settings.entryTitle(t) }}
|
||||
</h4>
|
||||
<component :is="settings.component" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
-->
|
||||
|
||||
<div class="md:grid md:grid-cols-3 md:gap-4">
|
||||
<div class="p-8 md:col-span-1">
|
||||
<h3 class="heading">
|
||||
|
|
@ -313,8 +286,6 @@ import { useI18n } from "@composables/i18n"
|
|||
import { useColorMode } from "@composables/theming"
|
||||
import { usePageHead } from "@composables/head"
|
||||
import { useService } from "dioc/vue"
|
||||
// NOTE: Old interceptors
|
||||
// import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { pipe } from "fp-ts/function"
|
||||
import * as O from "fp-ts/Option"
|
||||
import * as A from "fp-ts/Array"
|
||||
|
|
@ -346,22 +317,6 @@ const kernelInterceptorsWithSettings = computed(() =>
|
|||
)
|
||||
)
|
||||
|
||||
// NOTE: Old interceptors
|
||||
// const interceptorService: InterceptorService = useService(InterceptorService)
|
||||
// const interceptorsWithSettings = computed(() =>
|
||||
// pipe(
|
||||
// interceptorService.availableInterceptors.value,
|
||||
// A.filterMap((interceptor) =>
|
||||
// interceptor.settingsPageEntry
|
||||
// ? O.some([
|
||||
// interceptor.interceptorID,
|
||||
// interceptor.settingsPageEntry,
|
||||
// ] as const)
|
||||
// : O.none
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
|
||||
const ACCENT_COLOR = useSetting("THEME_COLOR")
|
||||
const TELEMETRY_ENABLED = useSetting("TELEMETRY_ENABLED")
|
||||
const EXPAND_NAVIGATION = useSetting("EXPAND_NAVIGATION")
|
||||
|
|
|
|||
|
|
@ -10,9 +10,6 @@ import { HistoryPlatformDef } from "./history"
|
|||
import { InfraPlatformDef } from "./infra"
|
||||
import { InspectorsPlatformDef } from "./inspectors"
|
||||
import { KernelInterceptorsPlatformDef } from "./kernel-interceptors"
|
||||
// NOTE: To be deprecated
|
||||
// import { InterceptorsPlatformDef } from "./interceptors"
|
||||
// import { IOPlatformDef } from "./io"
|
||||
import { LimitsPlatformDef } from "./limits"
|
||||
import { SettingsPlatformDef } from "./settings"
|
||||
import { SpotlightPlatformDef } from "./spotlight"
|
||||
|
|
@ -29,8 +26,6 @@ export type PlatformDef = {
|
|||
addedServices?: Array<ServiceClassInstance<unknown>>
|
||||
auth: AuthPlatformDef
|
||||
analytics?: AnalyticsPlatformDef
|
||||
// NOTE: To be deprecated
|
||||
// io: IOPlatformDef
|
||||
kernelIO: KernelIO
|
||||
instance: InstancePlatformDef
|
||||
sync: {
|
||||
|
|
@ -39,8 +34,6 @@ export type PlatformDef = {
|
|||
settings: SettingsPlatformDef
|
||||
history: HistoryPlatformDef
|
||||
}
|
||||
// NOTE: To be deprecated
|
||||
// interceptors: InterceptorsPlatformDef
|
||||
kernelInterceptors: KernelInterceptorsPlatformDef
|
||||
instance?: InstancePlatformDef
|
||||
additionalInspectors?: InspectorsPlatformDef
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { Container, ServiceClassInstance } from "dioc"
|
||||
import { Interceptor } from "~/services/interceptor.service"
|
||||
|
||||
export type PlatformInterceptorDef =
|
||||
| { type: "standalone"; interceptor: Interceptor }
|
||||
| {
|
||||
type: "service"
|
||||
// TODO: I don't think this type is effective, we have to come up with a better impl
|
||||
service: ServiceClassInstance<unknown> & {
|
||||
new (c: Container): Interceptor
|
||||
}
|
||||
}
|
||||
|
||||
export type InterceptorsPlatformDef = {
|
||||
default: string
|
||||
interceptors: PlatformInterceptorDef[]
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
import { TestContainer } from "dioc/testing"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ExtensionInspectorService } from "../extension.inspector"
|
||||
import { InspectionService } from "~/services/inspection"
|
||||
import { getDefaultRESTRequest } from "~/helpers/rest/default"
|
||||
import { ref } from "vue"
|
||||
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
|
||||
|
||||
vi.mock("~/modules/i18n", () => ({
|
||||
__esModule: true,
|
||||
getI18n: () => (x: string) => x,
|
||||
}))
|
||||
|
||||
describe("ExtensionInspectorService", () => {
|
||||
it("registers with the inspection service upon initialization", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const registerInspectorFn = vi.fn()
|
||||
|
||||
container.bindMock(InspectionService, {
|
||||
registerInspector: registerInspectorFn,
|
||||
})
|
||||
|
||||
const urlInspector = container.bind(ExtensionInspectorService)
|
||||
|
||||
expect(registerInspectorFn).toHaveBeenCalledOnce()
|
||||
expect(registerInspectorFn).toHaveBeenCalledWith(urlInspector)
|
||||
})
|
||||
|
||||
describe("getInspectorFor", () => {
|
||||
it("should return an inspector result when localhost is in URL and extension is not available", () => {
|
||||
const container = new TestContainer()
|
||||
const urlInspector = container.bind(ExtensionInspectorService)
|
||||
|
||||
const req = ref({
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "http://localhost:8000/api/data",
|
||||
})
|
||||
|
||||
const result = urlInspector.getInspections(req)
|
||||
|
||||
expect(result.value).toContainEqual(
|
||||
expect.objectContaining({ id: "url", isApplicable: true })
|
||||
)
|
||||
})
|
||||
|
||||
it("should not return an inspector result when localhost is not in URL", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
container.bindMock(ExtensionInterceptorService, {
|
||||
extensionStatus: ref("unknown-origin" as const),
|
||||
})
|
||||
|
||||
const urlInspector = container.bind(ExtensionInspectorService)
|
||||
|
||||
const req = ref({
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "http://example.com/api/data",
|
||||
})
|
||||
|
||||
const result = urlInspector.getInspections(req)
|
||||
|
||||
expect(result.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("should add the correct text to the results when extension is not installed", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
container.bindMock(ExtensionInterceptorService, {
|
||||
extensionStatus: ref("waiting" as const),
|
||||
})
|
||||
|
||||
const urlInspector = container.bind(ExtensionInspectorService)
|
||||
|
||||
const req = ref({
|
||||
...getDefaultRESTRequest(),
|
||||
endpoint: "http://localhost:8000/api/data",
|
||||
})
|
||||
|
||||
const result = urlInspector.getInspections(req)
|
||||
|
||||
expect(result.value).toHaveLength(1)
|
||||
expect(result.value[0]).toMatchObject({
|
||||
text: { type: "text", text: "inspections.url.extension_not_installed" },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { Service } from "dioc"
|
||||
import {
|
||||
InspectionService,
|
||||
Inspector,
|
||||
InspectorResult,
|
||||
} from "~/services/inspection"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||
import { computed, markRaw, Ref } from "vue"
|
||||
import IconAlertTriangle from "~icons/lucide/alert-triangle"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import { ExtensionInterceptorService } from "~/platform/std/interceptors/extension"
|
||||
|
||||
/**
|
||||
* This inspector is responsible for inspecting the URL of a request.
|
||||
* It checks if the URL contains localhost and if the extension is installed.
|
||||
* It also provides an action to enable the extension.
|
||||
*
|
||||
* NOTE: Initializing this service registers it as a inspector with the Inspection Service.
|
||||
*/
|
||||
export class ExtensionInspectorService extends Service implements Inspector {
|
||||
public static readonly ID = "EXTENSION_INSPECTOR_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public readonly inspectorID = "extension"
|
||||
|
||||
private readonly interceptorService = this.bind(InterceptorService)
|
||||
private readonly extensionService = this.bind(ExtensionInterceptorService)
|
||||
|
||||
private readonly inspection = this.bind(InspectionService)
|
||||
|
||||
override onServiceInit() {
|
||||
this.inspection.registerInspector(this)
|
||||
}
|
||||
|
||||
getInspections(req: Readonly<Ref<HoppRESTRequest | null>>) {
|
||||
const currentExtensionStatus = this.extensionService.extensionStatus
|
||||
|
||||
const isExtensionInstalled = computed(
|
||||
() => currentExtensionStatus.value === "available"
|
||||
)
|
||||
|
||||
const activeInterceptor = computed(
|
||||
() => this.interceptorService.currentInterceptorID.value
|
||||
)
|
||||
|
||||
const EXTENSION_ENABLED = computed(
|
||||
() => activeInterceptor.value === "extension"
|
||||
)
|
||||
|
||||
const AGENT_ENABLED = computed(() => activeInterceptor.value === "agent")
|
||||
|
||||
return computed(() => {
|
||||
const results: InspectorResult[] = []
|
||||
|
||||
if (!req.value) return results
|
||||
|
||||
const url = req.value.endpoint
|
||||
const localHostURLs = ["localhost", "127.0.0.1"]
|
||||
|
||||
const isContainLocalhost = localHostURLs.some((host) =>
|
||||
url.includes(host)
|
||||
)
|
||||
|
||||
// Prompt the user to install or enable the extension via inspector if the endpoint is `localhost`, and an interceptor other than `Agent` is active
|
||||
if (
|
||||
isContainLocalhost &&
|
||||
!AGENT_ENABLED.value &&
|
||||
(!EXTENSION_ENABLED.value || !isExtensionInstalled.value)
|
||||
) {
|
||||
let text
|
||||
|
||||
if (!isExtensionInstalled.value) {
|
||||
if (currentExtensionStatus.value === "unknown-origin") {
|
||||
text = this.t("inspections.url.extension_unknown_origin")
|
||||
} else {
|
||||
text = this.t("inspections.url.extension_not_installed")
|
||||
}
|
||||
} else if (!EXTENSION_ENABLED.value) {
|
||||
text = this.t("inspections.url.extention_not_enabled")
|
||||
} else {
|
||||
text = this.t("inspections.url.localhost")
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: "url",
|
||||
icon: markRaw(IconAlertTriangle),
|
||||
text: {
|
||||
type: "text",
|
||||
text: text,
|
||||
},
|
||||
action: {
|
||||
text: this.t("inspections.url.extention_enable_action"),
|
||||
apply: () => {
|
||||
this.interceptorService.currentInterceptorID.value = "extension"
|
||||
},
|
||||
},
|
||||
severity: 2,
|
||||
isApplicable: true,
|
||||
locations: {
|
||||
type: "url",
|
||||
},
|
||||
doc: {
|
||||
text: this.t("action.learn_more"),
|
||||
link: "https://docs.hoppscotch.io/documentation/features/interceptor#browser-extension",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,837 +0,0 @@
|
|||
import { CookieJarService } from "~/services/cookie-jar.service"
|
||||
import {
|
||||
Interceptor,
|
||||
InterceptorError,
|
||||
InterceptorService,
|
||||
RequestRunResult,
|
||||
} from "~/services/interceptor.service"
|
||||
import { Service } from "dioc"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { ref, watch } from "vue"
|
||||
import { z } from "zod"
|
||||
import { PersistenceService } from "~/services/persistence"
|
||||
import {
|
||||
CACertStore,
|
||||
ClientCertsStore,
|
||||
ClientCertStore,
|
||||
StoredClientCert,
|
||||
} from "./persisted-data"
|
||||
import axios, { CancelTokenSource } from "axios"
|
||||
import SettingsAgentInterceptor from "~/components/settings/Agent.vue"
|
||||
import AgentRootUIExtension from "~/components/interceptors/agent/RootExt.vue"
|
||||
import { UIExtensionService } from "~/services/ui-extension.service"
|
||||
import { x25519 } from "@noble/curves/ed25519.js"
|
||||
import { base16 } from "@scure/base"
|
||||
import { invokeAction } from "~/helpers/actions"
|
||||
import { preProcessRequest } from "../helpers"
|
||||
|
||||
type KeyValuePair = {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
type FormDataValue =
|
||||
| { Text: string }
|
||||
| {
|
||||
File: {
|
||||
filename: string
|
||||
data: number[]
|
||||
mime: string
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
export type RequestDef = {
|
||||
req_id: number
|
||||
|
||||
method: string
|
||||
endpoint: string
|
||||
|
||||
headers: KeyValuePair[]
|
||||
|
||||
body: BodyDef | null
|
||||
|
||||
validate_certs: boolean
|
||||
root_cert_bundle_files: number[][]
|
||||
client_cert: ClientCertDef | null
|
||||
|
||||
proxy?: {
|
||||
url: string
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
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 {
|
||||
const mime = value.type !== "" ? value.type : "application/octet-stream"
|
||||
|
||||
entries.push({
|
||||
key,
|
||||
value: {
|
||||
File: {
|
||||
filename: value.name,
|
||||
data: Array.from(new Uint8Array(await value.arrayBuffer())),
|
||||
mime,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { FormData: entries }
|
||||
}
|
||||
|
||||
throw new Error("Agent 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
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,
|
||||
proxyInfo: RequestDef["proxy"]
|
||||
): Promise<RequestDef> {
|
||||
const clientCertDomain = getURLDomain(axiosReq.url!)
|
||||
|
||||
const clientCert = clientCertDomain
|
||||
? clientCertificates.get(clientCertDomain)
|
||||
: null
|
||||
|
||||
const urlObj = new URL(axiosReq.url ?? "")
|
||||
|
||||
// If there are parameters in axiosReq.params, add them to the URL.
|
||||
if (axiosReq.params) {
|
||||
const params = new URLSearchParams(urlObj.search) // Taking in existing params if are any.
|
||||
|
||||
Object.entries(axiosReq.params as Record<string, string>).forEach(
|
||||
([key, value]) => {
|
||||
params.append(key, value)
|
||||
}
|
||||
)
|
||||
|
||||
urlObj.search = params.toString() // Now put back all the params in the URL.
|
||||
}
|
||||
|
||||
return {
|
||||
req_id: reqID,
|
||||
method: axiosReq.method ?? "GET",
|
||||
endpoint: urlObj.toString(), // This is the updated URL with parms.
|
||||
headers: Object.entries(axiosReq.headers ?? {})
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!(
|
||||
key.toLowerCase() === "content-type" &&
|
||||
value.toLowerCase() === "multipart/form-data"
|
||||
)
|
||||
) // Removing header, because this header will be set by agent.
|
||||
.map(([key, value]): KeyValuePair => ({ key, value })),
|
||||
|
||||
// NOTE: Injected parameters are already part of the URL
|
||||
|
||||
body: await processBody(axiosReq),
|
||||
root_cert_bundle_files: caCertificates.map((cert) =>
|
||||
Array.from(cert.certificate)
|
||||
),
|
||||
validate_certs: validateCerts,
|
||||
client_cert: clientCert ? convertClientCertToDefCert(clientCert) : null,
|
||||
proxy: proxyInfo,
|
||||
}
|
||||
}
|
||||
|
||||
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 = "agent_interceptor_ca_store"
|
||||
const CLIENT_CERTS_PERSIST_KEY = "agent_interceptor_client_certs_store"
|
||||
const VALIDATE_SSL_KEY = "agent_interceptor_validate_ssl"
|
||||
const AUTH_KEY_PERSIST_KEY = "agent_interceptor_auth_key"
|
||||
const SHARED_SECRET_PERSIST_KEY = "agent_interceptor_shared_secret"
|
||||
const PROXY_INFO_PERSIST_KEY = "agent_interceptor_proxy_info"
|
||||
|
||||
export class AgentInterceptorService extends Service implements Interceptor {
|
||||
public static readonly ID = "AGENT_INTERCEPTOR_SERVICE"
|
||||
|
||||
public interceptorID = "agent"
|
||||
|
||||
// TODO: Better User facing name
|
||||
public name = () => "Agent"
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
public supportsDigestAuth = true
|
||||
public supportsCookies = true
|
||||
public supportsBinaryContentType = false
|
||||
|
||||
private interceptorService = this.bind(InterceptorService)
|
||||
private cookieJarService = this.bind(CookieJarService)
|
||||
private persistenceService = this.bind(PersistenceService)
|
||||
private uiExtensionService = this.bind(UIExtensionService)
|
||||
|
||||
public isAgentRunning = ref(false)
|
||||
|
||||
private reqIDTicker = 0
|
||||
private cancelTokens: Map<number, CancelTokenSource> = new Map()
|
||||
|
||||
public settingsPageEntry = {
|
||||
entryTitle: () => "Agent", // TODO: i18n this
|
||||
component: SettingsAgentInterceptor,
|
||||
}
|
||||
|
||||
public caCertificates = ref<CACertificateEntry[]>([])
|
||||
|
||||
public clientCertificates = ref<Map<string, ClientCertificateEntry>>(
|
||||
new Map()
|
||||
)
|
||||
public validateCerts = ref(true)
|
||||
|
||||
public showRegistrationModal = ref(false)
|
||||
public authKey = ref<string | null>(null)
|
||||
public sharedSecretB16 = ref<string | null>(null)
|
||||
private registrationOTP = ref<string | null>(null)
|
||||
|
||||
public proxyInfo = ref<RequestDef["proxy"]>(undefined)
|
||||
|
||||
override onServiceInit() {
|
||||
// Register the Root UI Extension
|
||||
this.uiExtensionService.addRootUIExtension(AgentRootUIExtension)
|
||||
|
||||
const persistedAuthKey =
|
||||
this.persistenceService.getLocalConfig(AUTH_KEY_PERSIST_KEY)
|
||||
if (persistedAuthKey) {
|
||||
this.authKey.value = persistedAuthKey
|
||||
}
|
||||
|
||||
const sharedSecret = this.persistenceService.getLocalConfig(
|
||||
SHARED_SECRET_PERSIST_KEY
|
||||
)
|
||||
if (sharedSecret) {
|
||||
this.sharedSecretB16.value = sharedSecret
|
||||
}
|
||||
|
||||
const persistedProxyInfo = this.persistenceService.getLocalConfig(
|
||||
PROXY_INFO_PERSIST_KEY
|
||||
)
|
||||
if (persistedProxyInfo && persistedProxyInfo !== "null") {
|
||||
try {
|
||||
const proxyInfo = JSON.parse(persistedProxyInfo)
|
||||
this.proxyInfo.value = proxyInfo
|
||||
} catch (_e) {}
|
||||
}
|
||||
|
||||
// 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]
|
||||
}
|
||||
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]
|
||||
}
|
||||
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)
|
||||
)
|
||||
})
|
||||
|
||||
watch(this.authKey, (newAuthKey) => {
|
||||
if (newAuthKey) {
|
||||
this.persistenceService.setLocalConfig(AUTH_KEY_PERSIST_KEY, newAuthKey)
|
||||
} else {
|
||||
this.persistenceService.removeLocalConfig(AUTH_KEY_PERSIST_KEY)
|
||||
}
|
||||
})
|
||||
|
||||
watch(this.proxyInfo, (newProxyInfo) => {
|
||||
this.persistenceService.setLocalConfig(
|
||||
PROXY_INFO_PERSIST_KEY,
|
||||
JSON.stringify(newProxyInfo) ?? "null"
|
||||
)
|
||||
})
|
||||
|
||||
// Show registration UI if there is no auth key present
|
||||
watch(
|
||||
[this.interceptorService.currentInterceptor, this.authKey],
|
||||
([currentInterceptor, authKey]) => {
|
||||
if (
|
||||
currentInterceptor?.interceptorID === this.interceptorID &&
|
||||
authKey === null
|
||||
) {
|
||||
this.showRegistrationModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Verify if the agent registration still holds, else revoke the registration
|
||||
if (this.authKey.value) {
|
||||
;(async () => {
|
||||
try {
|
||||
const nonce = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const nonceB16 = base16.encode(nonce).toLowerCase()
|
||||
|
||||
const response = await axios.get(
|
||||
"http://localhost:9119/registered-handshake",
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
"X-Hopp-Nonce": nonceB16,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
|
||||
const responseNonceB16: string = response.headers["x-hopp-nonce"]
|
||||
const encryptedResponseBytes = response.data
|
||||
|
||||
const parsedData = await this.getDecryptedResponse<unknown>(
|
||||
responseNonceB16,
|
||||
encryptedResponseBytes
|
||||
)
|
||||
|
||||
// This should decrypt directly into `true` else registration failed
|
||||
if (parsedData !== true) {
|
||||
throw "handshake-mismatch"
|
||||
}
|
||||
} catch (e) {
|
||||
if (e === "handshake-mismatch") {
|
||||
this.sharedSecretB16.value = null
|
||||
this.authKey.value = null
|
||||
} else if (axios.isAxiosError(e) && e.status === 401) {
|
||||
this.sharedSecretB16.value = null
|
||||
this.authKey.value = null
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
}
|
||||
|
||||
public async checkAgentStatus(): Promise<void> {
|
||||
try {
|
||||
await this.performHandshake()
|
||||
this.isAgentRunning.value = true
|
||||
} catch (_error) {
|
||||
this.isAgentRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
public isAuthKeyPresent(): boolean {
|
||||
return this.authKey.value !== null
|
||||
}
|
||||
|
||||
private generateOTP(): string {
|
||||
// This generates a 6-digit numeric OTP
|
||||
return Math.floor(100000 + Math.random() * 900000).toString()
|
||||
}
|
||||
|
||||
public async performHandshake(): Promise<void> {
|
||||
const handshakeResponse = await axios.get("http://localhost:9119/handshake")
|
||||
if (
|
||||
handshakeResponse.data.status !== "success" &&
|
||||
handshakeResponse.data.__hoppscotch__agent__ === true
|
||||
) {
|
||||
throw new Error("Handshake failed")
|
||||
}
|
||||
}
|
||||
|
||||
public async initiateRegistration() {
|
||||
try {
|
||||
// Generate OTP and send registration request
|
||||
this.registrationOTP.value = this.generateOTP()
|
||||
|
||||
const registrationResponse = await axios.post(
|
||||
"http://localhost:9119/receive-registration",
|
||||
{
|
||||
registration: this.registrationOTP.value,
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
registrationResponse.data.message !== "Registration received and stored"
|
||||
) {
|
||||
throw new Error("Registration failed")
|
||||
}
|
||||
|
||||
// Registration successful, modal will handle showing the OTP input
|
||||
} catch (error) {
|
||||
console.error("Registration initiation failed:", error)
|
||||
throw error // Re-throw to let the modal handle the error
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyRegistration(userEnteredOTP: string) {
|
||||
try {
|
||||
const myPrivateKey = x25519.utils.randomPrivateKey()
|
||||
const myPublicKey = x25519.getPublicKey(myPrivateKey)
|
||||
|
||||
const myPublicKeyB16 = base16.encode(myPublicKey).toLowerCase()
|
||||
|
||||
const verificationResponse = await axios.post(
|
||||
"http://localhost:9119/verify-registration",
|
||||
{
|
||||
registration: userEnteredOTP,
|
||||
client_public_key_b16: myPublicKeyB16,
|
||||
}
|
||||
)
|
||||
|
||||
const newAuthKey = verificationResponse.data.auth_key
|
||||
const agentPublicKeyB16: string =
|
||||
verificationResponse.data.agent_public_key_b16
|
||||
|
||||
const agentPublicKey = base16.decode(agentPublicKeyB16.toUpperCase())
|
||||
|
||||
const sharedSecret = x25519.getSharedSecret(myPrivateKey, agentPublicKey)
|
||||
const sharedSecretB16 = base16.encode(sharedSecret).toLowerCase()
|
||||
|
||||
if (typeof newAuthKey === "string") {
|
||||
this.authKey.value = newAuthKey
|
||||
this.sharedSecretB16.value = sharedSecretB16
|
||||
this.persistenceService.setLocalConfig(AUTH_KEY_PERSIST_KEY, newAuthKey)
|
||||
this.persistenceService.setLocalConfig(
|
||||
SHARED_SECRET_PERSIST_KEY,
|
||||
sharedSecretB16
|
||||
)
|
||||
} else {
|
||||
throw new Error("Invalid auth key received")
|
||||
}
|
||||
|
||||
this.showRegistrationModal.value = false
|
||||
this.registrationOTP.value = null
|
||||
} catch (error) {
|
||||
console.error("Verification failed:", error)
|
||||
throw new Error("Verification failed")
|
||||
}
|
||||
}
|
||||
|
||||
private async getEncryptedRequestDef(
|
||||
def: RequestDef
|
||||
): Promise<[string, ArrayBuffer]> {
|
||||
const defJSON = JSON.stringify(def)
|
||||
const defJSONBytes = new TextEncoder().encode(defJSON)
|
||||
|
||||
const nonce = window.crypto.getRandomValues(new Uint8Array(12))
|
||||
const nonceB16 = base16.encode(nonce).toLowerCase()
|
||||
|
||||
const sharedSecretKeyBytes = base16.decode(
|
||||
this.sharedSecretB16.value!.toUpperCase()
|
||||
)
|
||||
|
||||
const sharedSecretKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecretKeyBytes,
|
||||
"AES-GCM",
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
const encryptedDef = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
sharedSecretKey,
|
||||
defJSONBytes
|
||||
)
|
||||
|
||||
return [nonceB16, encryptedDef]
|
||||
}
|
||||
|
||||
private async getDecryptedResponse<T>(
|
||||
nonceB16: string,
|
||||
responseData: ArrayBuffer
|
||||
) {
|
||||
const sharedSecretKeyBytes = base16.decode(
|
||||
this.sharedSecretB16.value!.toUpperCase()
|
||||
)
|
||||
|
||||
const sharedSecretKey = await window.crypto.subtle.importKey(
|
||||
"raw",
|
||||
sharedSecretKeyBytes,
|
||||
"AES-GCM",
|
||||
true,
|
||||
["encrypt", "decrypt"]
|
||||
)
|
||||
|
||||
const nonce = base16.decode(nonceB16.toUpperCase())
|
||||
|
||||
const plainTextDefBytes = await window.crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: nonce },
|
||||
sharedSecretKey,
|
||||
responseData
|
||||
)
|
||||
|
||||
const plainText = new TextDecoder().decode(plainTextDefBytes)
|
||||
|
||||
return JSON.parse(plainText) as T
|
||||
}
|
||||
|
||||
public runRequest(
|
||||
req: AxiosRequestConfig
|
||||
): RequestRunResult<InterceptorError> {
|
||||
// TODO: Check if auth key is defined ?
|
||||
|
||||
const processedReq = preProcessRequest(req)
|
||||
|
||||
const relevantCookies = this.cookieJarService.getCookiesForURL(
|
||||
new URL(processedReq.url!)
|
||||
)
|
||||
|
||||
if (relevantCookies.length > 0) {
|
||||
processedReq.headers!["Cookie"] = relevantCookies
|
||||
.map((cookie) => `${cookie.name!}=${cookie.value!}`)
|
||||
.join(";")
|
||||
}
|
||||
|
||||
const reqID = this.reqIDTicker++
|
||||
|
||||
const cancelTokenSource = axios.CancelToken.source()
|
||||
this.cancelTokens.set(reqID, cancelTokenSource)
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
const cancelTokenSource = this.cancelTokens.get(reqID)
|
||||
if (cancelTokenSource) {
|
||||
cancelTokenSource.cancel("Request cancelled")
|
||||
this.cancelTokens.delete(reqID)
|
||||
axios
|
||||
.post(
|
||||
`http://localhost:9119/cancel-request/${reqID}`,
|
||||
{},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
},
|
||||
}
|
||||
)
|
||||
.catch((error) => console.error("Error cancelling request:", error))
|
||||
}
|
||||
},
|
||||
response: (async () => {
|
||||
await this.checkAgentStatus()
|
||||
|
||||
if (!this.isAgentRunning.value || !this.authKey.value) {
|
||||
invokeAction("agent.open-registration-modal")
|
||||
|
||||
return E.left(<InterceptorError>{
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const requestDef = await convertToRequestDef(
|
||||
processedReq,
|
||||
reqID,
|
||||
this.caCertificates.value,
|
||||
this.clientCertificates.value,
|
||||
this.validateCerts.value,
|
||||
this.proxyInfo.value
|
||||
)
|
||||
|
||||
const [nonceB16, encryptedDef] =
|
||||
await this.getEncryptedRequestDef(requestDef)
|
||||
|
||||
try {
|
||||
const http_response = await axios.post(
|
||||
"http://localhost:9119/request",
|
||||
encryptedDef,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.authKey.value}`,
|
||||
"X-Hopp-Nonce": nonceB16,
|
||||
"Content-Type": "application/octet-stream",
|
||||
},
|
||||
cancelToken: cancelTokenSource.token,
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
)
|
||||
|
||||
const responseNonceB16: string = http_response.headers["x-hopp-nonce"]
|
||||
const encryptedResponseBytes = http_response.data
|
||||
|
||||
const response = await this.getDecryptedResponse<RunRequestResponse>(
|
||||
responseNonceB16,
|
||||
encryptedResponseBytes
|
||||
)
|
||||
|
||||
// TODO: Run it against a Zod Schema validation
|
||||
|
||||
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) {
|
||||
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"),
|
||||
},
|
||||
})
|
||||
}
|
||||
})(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,80 +0,0 @@
|
|||
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>
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
import * as E from "fp-ts/Either"
|
||||
import { AxiosRequestConfig } from "axios"
|
||||
import { Service } from "dioc"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import {
|
||||
Interceptor,
|
||||
InterceptorError,
|
||||
RequestRunResult,
|
||||
} from "~/services/interceptor.service"
|
||||
import { computed, readonly, ref } from "vue"
|
||||
import { browserIsChrome, browserIsFirefox } from "~/helpers/utils/userAgent"
|
||||
import SettingsExtension from "~/components/settings/Extension.vue"
|
||||
import InterceptorsExtensionSubtitle from "~/components/interceptors/ExtensionSubtitle.vue"
|
||||
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
|
||||
import { until } from "@vueuse/core"
|
||||
import { preProcessRequest } from "./helpers"
|
||||
|
||||
export const defineSubscribableObject = <T extends object>(obj: T) => {
|
||||
const proxyObject = {
|
||||
...obj,
|
||||
_subscribers: {} as {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
[key in keyof T]?: ((...args: any[]) => any)[]
|
||||
},
|
||||
subscribe(prop: keyof T, func: (...args: any[]) => any): void {
|
||||
if (Array.isArray(this._subscribers[prop])) {
|
||||
this._subscribers[prop]?.push(func)
|
||||
} else {
|
||||
this._subscribers[prop] = [func]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type SubscribableProxyObject = typeof proxyObject
|
||||
|
||||
return new Proxy(proxyObject, {
|
||||
set(obj, prop, newVal) {
|
||||
obj[prop as keyof SubscribableProxyObject] = newVal
|
||||
|
||||
const currentSubscribers = obj._subscribers[prop as keyof T]
|
||||
|
||||
if (Array.isArray(currentSubscribers)) {
|
||||
for (const subscriber of currentSubscribers) {
|
||||
subscriber(newVal)
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TODO: Rework this to deal with individual requests rather than cancel all
|
||||
export const cancelRunningExtensionRequest = () => {
|
||||
window.__POSTWOMAN_EXTENSION_HOOK__?.cancelRequest()
|
||||
}
|
||||
|
||||
export type ExtensionStatus = "available" | "unknown-origin" | "waiting"
|
||||
|
||||
/**
|
||||
* This service is responsible for defining the extension interceptor.
|
||||
*/
|
||||
export class ExtensionInterceptorService
|
||||
extends Service
|
||||
implements Interceptor
|
||||
{
|
||||
public static readonly ID = "EXTENSION_INTERCEPTOR_SERVICE"
|
||||
|
||||
private _extensionStatus = ref<ExtensionStatus>("waiting")
|
||||
|
||||
/**
|
||||
* The status of the extension, whether it's available, or not.
|
||||
*/
|
||||
public extensionStatus = readonly(this._extensionStatus)
|
||||
|
||||
/**
|
||||
* The version of the extension, if available.
|
||||
*/
|
||||
public extensionVersion = computed(() => {
|
||||
if (this.extensionStatus.value === "available") {
|
||||
return window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/**
|
||||
* Whether the extension is installed in Chrome or not.
|
||||
*/
|
||||
public chromeExtensionInstalled = computed(
|
||||
() => this.extensionStatus.value === "available" && browserIsChrome()
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the extension is installed in Firefox or not.
|
||||
*/
|
||||
public firefoxExtensionInstalled = computed(
|
||||
() => this.extensionStatus.value === "available" && browserIsFirefox()
|
||||
)
|
||||
|
||||
public interceptorID = "extension"
|
||||
|
||||
public settingsPageEntry: Interceptor["settingsPageEntry"] = {
|
||||
entryTitle: (t) => t("settings.extensions"),
|
||||
component: SettingsExtension,
|
||||
}
|
||||
|
||||
public selectorSubtitle = InterceptorsExtensionSubtitle
|
||||
|
||||
public selectable = { type: "selectable" as const }
|
||||
|
||||
override onServiceInit() {
|
||||
this.listenForExtensionStatus()
|
||||
}
|
||||
|
||||
private listenForExtensionStatus() {
|
||||
const extensionPollIntervalId = ref<ReturnType<typeof setInterval>>()
|
||||
|
||||
if (window.__HOPP_EXTENSION_STATUS_PROXY__) {
|
||||
this._extensionStatus.value =
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__.status
|
||||
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__.subscribe(
|
||||
"status",
|
||||
(status: ExtensionStatus) => {
|
||||
this._extensionStatus.value = status
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const statusProxy = defineSubscribableObject({
|
||||
status: "waiting" as ExtensionStatus,
|
||||
})
|
||||
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__ = statusProxy
|
||||
statusProxy.subscribe(
|
||||
"status",
|
||||
(status: ExtensionStatus) => (this._extensionStatus.value = status)
|
||||
)
|
||||
|
||||
/**
|
||||
* Keeping identifying extension backward compatible
|
||||
* We are assuming the default version is 0.24 or later. So if the extension exists, its identified immediately,
|
||||
* then we use a poll to find the version, this will get the version for 0.24 and any other version
|
||||
* of the extension, but will have a slight lag.
|
||||
* 0.24 users will get the benefits of 0.24, while the extension won't break for the old users
|
||||
*/
|
||||
extensionPollIntervalId.value = setInterval(() => {
|
||||
if (typeof window.__POSTWOMAN_EXTENSION_HOOK__ !== "undefined") {
|
||||
if (extensionPollIntervalId.value)
|
||||
clearInterval(extensionPollIntervalId.value)
|
||||
|
||||
const version = window.__POSTWOMAN_EXTENSION_HOOK__.getVersion()
|
||||
|
||||
// When the version is not 0.24 or higher, the extension wont do this. so we have to do it manually
|
||||
if (
|
||||
version.major === 0 &&
|
||||
version.minor <= 23 &&
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__
|
||||
) {
|
||||
window.__HOPP_EXTENSION_STATUS_PROXY__.status = "available"
|
||||
}
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
public name(t: ReturnType<typeof getI18n>) {
|
||||
return computed(() => {
|
||||
const version = window.__POSTWOMAN_EXTENSION_HOOK__?.getVersion()
|
||||
|
||||
if (this.extensionStatus.value === "available" && version) {
|
||||
const { major, minor } = version
|
||||
return `${t("settings.extensions")}: v${major}.${minor}`
|
||||
}
|
||||
return `${t("settings.extensions")}: ${t(
|
||||
"settings.extension_ver_not_reported"
|
||||
)}`
|
||||
})
|
||||
}
|
||||
|
||||
private async runRequestOnExtension(
|
||||
req: AxiosRequestConfig
|
||||
): RequestRunResult["response"] {
|
||||
// wait for the extension to resolve
|
||||
await until(this.extensionStatus).toMatch(
|
||||
(status) => status !== "waiting",
|
||||
{
|
||||
timeout: 1000,
|
||||
}
|
||||
)
|
||||
|
||||
const extensionHook = window.__POSTWOMAN_EXTENSION_HOOK__
|
||||
if (!extensionHook) {
|
||||
return E.left(<InterceptorError>{
|
||||
// TODO: i18n this
|
||||
humanMessage: {
|
||||
heading: () => "Extension not found",
|
||||
description: () => "Heading not found",
|
||||
},
|
||||
error: "NO_PW_EXT_HOOK",
|
||||
component: InterceptorsErrorPlaceholder,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await extensionHook.sendRequest({
|
||||
...req,
|
||||
headers: req.headers ?? {},
|
||||
wantsBinary: true,
|
||||
})
|
||||
|
||||
return E.right(result)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// TODO: improve type checking
|
||||
if ((e as any).response) {
|
||||
return E.right((e as any).response)
|
||||
}
|
||||
|
||||
return E.left(<InterceptorError>{
|
||||
// TODO: i18n this
|
||||
humanMessage: {
|
||||
heading: () => "Extension error",
|
||||
description: () => "Failed running request on extension",
|
||||
},
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public runRequest(
|
||||
request: AxiosRequestConfig
|
||||
): RequestRunResult<InterceptorError> {
|
||||
const processedReq = preProcessRequest(request)
|
||||
|
||||
return {
|
||||
cancel: cancelRunningExtensionRequest,
|
||||
response: this.runRequestOnExtension(processedReq),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
import { AxiosRequestConfig } from "axios"
|
||||
import { cloneDeep } from "lodash-es"
|
||||
import { useSetting } from "~/composables/settings"
|
||||
|
||||
// Helper function to check if a string is already encoded
|
||||
const isEncoded = (value: string) => {
|
||||
try {
|
||||
return value !== decodeURIComponent(value)
|
||||
} catch (_e) {
|
||||
return false // in case of malformed URI sequence
|
||||
}
|
||||
}
|
||||
|
||||
export const preProcessRequest = (
|
||||
req: AxiosRequestConfig
|
||||
): AxiosRequestConfig => {
|
||||
const reqClone = cloneDeep(req)
|
||||
const encodeMode = useSetting("ENCODE_MODE")
|
||||
|
||||
// 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()) {
|
||||
let finalValue = value
|
||||
if (
|
||||
encodeMode.value === "enable" ||
|
||||
(encodeMode.value === "auto" &&
|
||||
/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(value))
|
||||
) {
|
||||
// Check if the value is already encoded (e.g., contains % symbols)
|
||||
if (!isEncoded(value)) {
|
||||
finalValue = encodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
|
||||
// Set the parameter with the final value
|
||||
url.searchParams.append(key, finalValue)
|
||||
}
|
||||
|
||||
// decode the URL to prevent double encoding
|
||||
reqClone.url = decodeURIComponent(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
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { Interceptor, RequestRunResult } from "~/services/interceptor.service"
|
||||
import axios, { AxiosRequestConfig, CancelToken } from "axios"
|
||||
import * as E from "fp-ts/Either"
|
||||
import { preProcessRequest } from "./helpers"
|
||||
import { v4 } from "uuid"
|
||||
import { settingsStore } from "~/newstore/settings"
|
||||
import { decodeB64StringToArrayBuffer } from "~/helpers/utils/b64"
|
||||
import SettingsProxy from "~/components/settings/Proxy.vue"
|
||||
import { getDefaultProxyUrl } from "~/helpers/proxyUrl"
|
||||
|
||||
type ProxyHeaders = {
|
||||
"multipart-part-key"?: string
|
||||
}
|
||||
|
||||
type ProxyPayloadType =
|
||||
| FormData
|
||||
| (AxiosRequestConfig & { wantsBinary: true; accessToken: string })
|
||||
|
||||
const getProxyPayload = (
|
||||
req: AxiosRequestConfig,
|
||||
multipartKey: string | null
|
||||
) => {
|
||||
let payload: ProxyPayloadType = {
|
||||
...req,
|
||||
wantsBinary: true,
|
||||
accessToken: import.meta.env.VITE_PROXYSCOTCH_ACCESS_TOKEN ?? "",
|
||||
}
|
||||
|
||||
if (payload.data instanceof FormData) {
|
||||
const formData = payload.data
|
||||
payload.data = ""
|
||||
formData.append(multipartKey!, JSON.stringify(payload))
|
||||
payload = formData
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
async function runRequest(
|
||||
req: AxiosRequestConfig,
|
||||
cancelToken: CancelToken
|
||||
): RequestRunResult["response"] {
|
||||
const defaultProxyURL = await getDefaultProxyUrl()
|
||||
|
||||
const multipartKey =
|
||||
req.data instanceof FormData ? `proxyRequestData-${v4()}` : null
|
||||
|
||||
const headers =
|
||||
req.data instanceof FormData
|
||||
? <ProxyHeaders>{
|
||||
"multipart-part-key": multipartKey,
|
||||
}
|
||||
: <ProxyHeaders>{}
|
||||
|
||||
const payload = getProxyPayload(req, multipartKey)
|
||||
|
||||
try {
|
||||
// TODO: Validation for the proxy result
|
||||
const { data } = await axios.post(
|
||||
settingsStore.value.PROXY_URL ?? defaultProxyURL,
|
||||
payload,
|
||||
{
|
||||
headers,
|
||||
cancelToken,
|
||||
}
|
||||
)
|
||||
|
||||
if (!data.success) {
|
||||
return E.left({
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => data.data?.message ?? t("error.proxy_error"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (data.isBinary) {
|
||||
data.data = decodeB64StringToArrayBuffer(data.data)
|
||||
}
|
||||
|
||||
return E.right(data)
|
||||
} catch (e) {
|
||||
if (axios.isCancel(e)) {
|
||||
return E.left("cancellation")
|
||||
}
|
||||
return E.left({
|
||||
humanMessage: {
|
||||
heading: (t) => t("error.network_fail"),
|
||||
description: (t) => t("helpers.network_fail"),
|
||||
},
|
||||
error: e,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const proxyInterceptor: Interceptor = {
|
||||
interceptorID: "proxy",
|
||||
name: (t) => t("settings.proxy"),
|
||||
selectable: { type: "selectable" },
|
||||
supportsBinaryContentType: false,
|
||||
settingsPageEntry: {
|
||||
entryTitle: (t) => t("settings.proxy"),
|
||||
component: SettingsProxy,
|
||||
},
|
||||
runRequest(req) {
|
||||
const cancelToken = axios.CancelToken.source()
|
||||
|
||||
const processedReq = preProcessRequest(req)
|
||||
|
||||
const promise = runRequest(processedReq, cancelToken.token)
|
||||
|
||||
return {
|
||||
cancel: () => cancelToken.cancel(),
|
||||
response: promise,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ import {
|
|||
import { KernelInterceptorAgentStore } from "./store"
|
||||
import SettingsAgent from "~/components/settings/Agent.vue"
|
||||
import SettingsAgentSubtitle from "~/components/settings/AgentSubtitle.vue"
|
||||
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
|
||||
import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue"
|
||||
import { CookieJarService } from "~/services/cookie-jar.service"
|
||||
|
||||
export class AgentKernelInterceptorService
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type {
|
|||
import { getI18n } from "~/modules/i18n"
|
||||
import { preProcessRelayRequest } from "~/helpers/functional/process-request"
|
||||
|
||||
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
|
||||
import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue"
|
||||
|
||||
export class BrowserKernelInterceptorService
|
||||
extends Service
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type {
|
|||
KernelInterceptorError,
|
||||
} from "~/services/kernel-interceptor.service"
|
||||
import { CookieJarService } from "~/services/cookie-jar.service"
|
||||
import InterceptorsErrorPlaceholder from "~/components/interceptors/ErrorPlaceholder.vue"
|
||||
import InterceptorsErrorPlaceholder from "~/components/settings/InterceptorErrorPlaceholder.vue"
|
||||
import SettingsNative from "~/components/settings/Native.vue"
|
||||
import { KernelInterceptorNativeStore } from "./store"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,198 +0,0 @@
|
|||
import { describe, expect, it, vi } from "vitest"
|
||||
import { Interceptor, InterceptorService } from "../interceptor.service"
|
||||
import { TestContainer } from "dioc/testing"
|
||||
|
||||
describe("InterceptorService", () => {
|
||||
it("initaly there are no interceptors defined", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
expect(service.availableInterceptors.value).toEqual([])
|
||||
})
|
||||
|
||||
it("currentInterceptorID should be null if no interceptors are defined", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
expect(service.currentInterceptorID.value).toBeNull()
|
||||
})
|
||||
|
||||
it("currentInterceptorID should be set if there is an interceptor defined", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
service.registerInterceptor({
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: () => {
|
||||
throw new Error("Not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
expect(service.currentInterceptorID.value).toEqual("test")
|
||||
})
|
||||
|
||||
it("currentInterceptorID cannot be set to null if there are interceptors defined", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
service.registerInterceptor({
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: () => {
|
||||
throw new Error("Not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
service.currentInterceptorID.value = null
|
||||
expect(service.currentInterceptorID.value).not.toBeNull()
|
||||
})
|
||||
|
||||
it("currentInterceptorID cannot be set to an unknown interceptor ID", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
service.registerInterceptor({
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: () => {
|
||||
throw new Error("Not implemented")
|
||||
},
|
||||
})
|
||||
|
||||
service.currentInterceptorID.value = "unknown"
|
||||
expect(service.currentInterceptorID.value).not.toEqual("unknown")
|
||||
})
|
||||
|
||||
it("currentInterceptor points to the instance of the currently selected interceptor", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
const interceptor = {
|
||||
interceptorID: "test",
|
||||
name: () => "test interceptor",
|
||||
selectable: { type: "selectable" as const },
|
||||
runRequest: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
service.registerInterceptor(interceptor)
|
||||
service.currentInterceptorID.value = "test"
|
||||
|
||||
expect(service.currentInterceptor.value).toBe(interceptor)
|
||||
})
|
||||
|
||||
it("currentInterceptor updates when the currentInterceptorID changes", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
const interceptor = {
|
||||
interceptorID: "test",
|
||||
name: () => "test interceptor",
|
||||
selectable: { type: "selectable" as const },
|
||||
runRequest: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
const interceptor_2 = {
|
||||
interceptorID: "test2",
|
||||
name: () => "test interceptor",
|
||||
selectable: { type: "selectable" as const },
|
||||
runRequest: () => {
|
||||
throw new Error("not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
service.registerInterceptor(interceptor)
|
||||
service.registerInterceptor(interceptor_2)
|
||||
|
||||
service.currentInterceptorID.value = "test"
|
||||
|
||||
expect(service.currentInterceptor.value).toBe(interceptor)
|
||||
|
||||
service.currentInterceptorID.value = "test2"
|
||||
expect(service.currentInterceptor.value).not.toBe(interceptor)
|
||||
expect(service.currentInterceptor.value).toBe(interceptor_2)
|
||||
})
|
||||
|
||||
describe("registerInterceptor", () => {
|
||||
it("should register the interceptor", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
const interceptor: Interceptor = {
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: () => {
|
||||
throw new Error("Not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
service.registerInterceptor(interceptor)
|
||||
|
||||
expect(service.availableInterceptors.value).toEqual([interceptor])
|
||||
})
|
||||
|
||||
it("should set the current interceptor ID to non-null after the initial registration", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
const interceptor: Interceptor = {
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: () => {
|
||||
throw new Error("Not implemented")
|
||||
},
|
||||
}
|
||||
|
||||
service.registerInterceptor(interceptor)
|
||||
|
||||
expect(service.currentInterceptorID.value).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("runRequest", () => {
|
||||
it("should throw an error if no interceptor is selected", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
expect(() => service.runRequest({})).toThrowError()
|
||||
})
|
||||
|
||||
it("asks the current interceptor to run the request", () => {
|
||||
const container = new TestContainer()
|
||||
|
||||
const service = container.bind(InterceptorService)
|
||||
|
||||
const interceptor: Interceptor = {
|
||||
interceptorID: "test",
|
||||
name: () => "Test Interceptor",
|
||||
selectable: { type: "selectable" },
|
||||
runRequest: vi.fn(),
|
||||
}
|
||||
|
||||
service.registerInterceptor(interceptor)
|
||||
|
||||
service.runRequest({})
|
||||
|
||||
expect(interceptor.runRequest).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,274 +0,0 @@
|
|||
import * as E from "fp-ts/Either"
|
||||
import { Service } from "dioc"
|
||||
import { refWithControl } from "@vueuse/core"
|
||||
import { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import type { getI18n } from "~/modules/i18n"
|
||||
import { throwError } from "~/helpers/functional/error"
|
||||
import {
|
||||
Component,
|
||||
MaybeRef,
|
||||
Ref,
|
||||
computed,
|
||||
reactive,
|
||||
watch,
|
||||
unref,
|
||||
markRaw,
|
||||
} from "vue"
|
||||
|
||||
/**
|
||||
* Defines the response data from an interceptor request run.
|
||||
*/
|
||||
export type NetworkResponse = AxiosResponse<unknown> & {
|
||||
config?: {
|
||||
timeData?: {
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Optional additional fields with special optional metadata that can be used
|
||||
*/
|
||||
additional?: {
|
||||
/**
|
||||
* By the HTTP spec, we can have multiple headers with the same name, but
|
||||
* this is not accessible in the AxiosResponse type as the headers there are Record<string, string>
|
||||
* (and hence cannot have secondary values).
|
||||
*
|
||||
* If this value is present, headers can be read from here which will have the data.
|
||||
*/
|
||||
multiHeaders?: Array<{
|
||||
key: string
|
||||
value: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the errors that can occur during interceptor request run.
|
||||
*/
|
||||
export type InterceptorError =
|
||||
| "cancellation"
|
||||
| {
|
||||
humanMessage: {
|
||||
heading: (t: ReturnType<typeof getI18n>) => string
|
||||
description: (t: ReturnType<typeof getI18n>) => string
|
||||
}
|
||||
error?: unknown
|
||||
component?: Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines the result of an interceptor request run.
|
||||
*/
|
||||
export type RequestRunResult<Err extends InterceptorError = InterceptorError> =
|
||||
{
|
||||
/**
|
||||
* Cancels the interceptor request run.
|
||||
*/
|
||||
cancel: () => void
|
||||
|
||||
/**
|
||||
* Promise that resolves when the interceptor request run is finished.
|
||||
*/
|
||||
response: Promise<E.Either<Err, NetworkResponse>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines whether an interceptor is selectable or not
|
||||
*/
|
||||
export type InterceptorSelectableStatus<CustomComponentProps = any> =
|
||||
| { type: "selectable" }
|
||||
| {
|
||||
type: "unselectable"
|
||||
reason:
|
||||
| {
|
||||
type: "text"
|
||||
text: (t: ReturnType<typeof getI18n>) => string
|
||||
action?: {
|
||||
text: (t: ReturnType<typeof getI18n>) => string
|
||||
onActionClick: () => void
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: "custom"
|
||||
component: Component<CustomComponentProps>
|
||||
props: CustomComponentProps
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An interceptor is an object that defines how to run a Hoppscotch request.
|
||||
*/
|
||||
export type Interceptor<Err extends InterceptorError = InterceptorError> = {
|
||||
/**
|
||||
* The ID of the interceptor. This should be unique across all registered interceptors.
|
||||
*/
|
||||
interceptorID: string
|
||||
|
||||
/**
|
||||
* The function that returns the name of the interceptor.
|
||||
* @param t The i18n function.
|
||||
*/
|
||||
name: (t: ReturnType<typeof getI18n>) => MaybeRef<string>
|
||||
|
||||
/**
|
||||
* Defines whether the interceptor has support for cookies.
|
||||
* If this field is undefined, it is assumed as *not supporting* cookies.
|
||||
*/
|
||||
supportsCookies?: boolean
|
||||
|
||||
/**
|
||||
* Defines whether the interceptor has support for Digest Auth.
|
||||
* If this field is undefined, it is assumed as *not supporting* the Digest Auth type.
|
||||
*/
|
||||
supportsDigestAuth?: boolean
|
||||
|
||||
/**
|
||||
* Defines whether the interceptor has support for Binary (file) content type.
|
||||
* If this field is undefined, it is assumed as *supporting* the Binary content type.
|
||||
*/
|
||||
supportsBinaryContentType?: boolean
|
||||
|
||||
/**
|
||||
* Defines what to render in the Interceptor section of the Settings page.
|
||||
* Use this space to define interceptor specific settings.
|
||||
* Not setting this will lead to nothing being rendered about this interceptor in the settings page.
|
||||
*/
|
||||
settingsPageEntry?: {
|
||||
/**
|
||||
* The title of the interceptor entry in the settings page.
|
||||
*/
|
||||
entryTitle: (t: ReturnType<typeof getI18n>) => string
|
||||
|
||||
/**
|
||||
* The component to render in the settings page.
|
||||
*/
|
||||
component: Component
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what to render under the entry for the interceptor in the Interceptor selector.
|
||||
*/
|
||||
selectorSubtitle?: Component
|
||||
|
||||
/**
|
||||
* Defines whether the interceptor is selectable or not.
|
||||
*/
|
||||
selectable: MaybeRef<InterceptorSelectableStatus<unknown>>
|
||||
|
||||
/**
|
||||
* Runs the interceptor on the given request.
|
||||
* NOTE: Make sure this function doesn't throw, instead when an error occurs, return a Left Either with the error.
|
||||
* @param request The request to run the interceptor on.
|
||||
*/
|
||||
runRequest: (request: AxiosRequestConfig) => RequestRunResult<Err>
|
||||
}
|
||||
|
||||
/**
|
||||
* This service deals with the registration and execution of
|
||||
* interceptors for request execution.
|
||||
*/
|
||||
export class InterceptorService extends Service {
|
||||
public static readonly ID = "INTERCEPTOR_SERVICE"
|
||||
|
||||
private interceptors: Map<string, Interceptor> = reactive(new Map())
|
||||
|
||||
/**
|
||||
* The ID of the currently selected interceptor.
|
||||
* If `null`, there are no interceptors registered or none can be selected.
|
||||
*/
|
||||
public currentInterceptorID: Ref<string | null> = refWithControl(
|
||||
null as string | null,
|
||||
{
|
||||
onBeforeChange: (value) => {
|
||||
if (!value) {
|
||||
// Only allow `null` if there are no interceptors
|
||||
return this.availableInterceptors.value.length === 0
|
||||
}
|
||||
|
||||
if (value && !this.interceptors.has(value)) {
|
||||
console.warn(
|
||||
"Attempt to set current interceptor ID to unknown ID is ignored"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* List of interceptors that are registered with the service.
|
||||
*/
|
||||
public availableInterceptors = computed(() =>
|
||||
Array.from(this.interceptors.values())
|
||||
)
|
||||
|
||||
/**
|
||||
* Gives an instance to the current interceptor.
|
||||
* NOTE: Do not update from here, this is only for reading.
|
||||
*/
|
||||
public currentInterceptor = computed(() => {
|
||||
if (this.currentInterceptorID.value === null) return null
|
||||
|
||||
return this.interceptors.get(this.currentInterceptorID.value)
|
||||
})
|
||||
|
||||
override onServiceInit() {
|
||||
// If the current interceptor is unselectable, select the first selectable one, else null
|
||||
watch([() => this.interceptors, this.currentInterceptorID], () => {
|
||||
if (!this.currentInterceptorID.value) return
|
||||
|
||||
const interceptor = this.interceptors.get(this.currentInterceptorID.value)
|
||||
|
||||
if (!interceptor) {
|
||||
this.currentInterceptorID.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (unref(interceptor.selectable).type === "unselectable") {
|
||||
this.currentInterceptorID.value =
|
||||
this.availableInterceptors.value.filter(
|
||||
(interceptor) => unref(interceptor.selectable).type === "selectable"
|
||||
)[0]?.interceptorID ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register an interceptor with the service.
|
||||
* @param interceptor The interceptor to register
|
||||
*/
|
||||
public registerInterceptor(interceptor: Interceptor) {
|
||||
// markRaw so that interceptor state by itself is not fully marked reactive
|
||||
this.interceptors.set(interceptor.interceptorID, markRaw(interceptor))
|
||||
|
||||
if (this.currentInterceptorID.value === null) {
|
||||
this.currentInterceptorID.value = interceptor.interceptorID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a request through the currently selected interceptor.
|
||||
* @param req The request to run
|
||||
* @throws If no interceptor is selected
|
||||
*/
|
||||
public runRequest(req: AxiosRequestConfig): RequestRunResult {
|
||||
if (!this.currentInterceptorID.value) {
|
||||
throw new Error("No interceptor selected")
|
||||
}
|
||||
|
||||
const interceptor =
|
||||
this.interceptors.get(this.currentInterceptorID.value) ??
|
||||
throwError(
|
||||
"Current Interceptor ID is not found in the list of registered interceptors"
|
||||
)
|
||||
|
||||
return interceptor.runRequest(req)
|
||||
}
|
||||
|
||||
public static convertArrayBufferToString(data: ArrayBuffer): string {
|
||||
return new TextDecoder().decode(data).replace(/\0+$/, "")
|
||||
}
|
||||
}
|
||||
|
|
@ -292,14 +292,14 @@ describe("PersistenceService", () => {
|
|||
|
||||
it(`shows an error and sets the entry as a backup in localStorage if "${vuexKey}" read from localStorage doesn't match the schema`, async () => {
|
||||
// Invalid shape for `vuex`
|
||||
// `postwoman.settings.CURRENT_INTERCEPTOR_ID` -> `string`
|
||||
// `postwoman.settings.CURRENT_KERNEL_INTERCEPTOR_ID` -> should be `string`, not `number`
|
||||
const vuexData = {
|
||||
...VUEX_DATA_MOCK,
|
||||
postwoman: {
|
||||
...VUEX_DATA_MOCK.postwoman,
|
||||
settings: {
|
||||
...VUEX_DATA_MOCK.postwoman.settings,
|
||||
CURRENT_INTERCEPTOR_ID: 1234,
|
||||
CURRENT_KERNEL_INTERCEPTOR_ID: 1234,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ const SettingsDefSchema = z.object({
|
|||
syncHistory: z.boolean(),
|
||||
syncEnvironments: z.boolean(),
|
||||
PROXY_URL: z.string(),
|
||||
CURRENT_INTERCEPTOR_ID: z.string(),
|
||||
CURRENT_KERNEL_INTERCEPTOR_ID: z.string(),
|
||||
URL_EXCLUDES: z.object({
|
||||
auth: z.boolean(),
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import { Ref, computed, effectScope, markRaw, ref, unref, watch } from "vue"
|
||||
import { getI18n } from "~/modules/i18n"
|
||||
import {
|
||||
SpotlightSearcher,
|
||||
SpotlightSearcherResult,
|
||||
SpotlightSearcherSessionState,
|
||||
SpotlightService,
|
||||
} from ".."
|
||||
|
||||
import { Service } from "dioc"
|
||||
import MiniSearch from "minisearch"
|
||||
import IconCheckCircle from "~/components/app/spotlight/entry/IconSelected.vue"
|
||||
import { InterceptorService } from "~/services/interceptor.service"
|
||||
import IconCircle from "~icons/lucide/circle"
|
||||
|
||||
/**
|
||||
* This searcher is responsible for searching through the interceptor.
|
||||
* And switching between them.
|
||||
*/
|
||||
export class InterceptorSpotlightSearcherService
|
||||
extends Service
|
||||
implements SpotlightSearcher
|
||||
{
|
||||
public static readonly ID = "INTERCEPTOR_SPOTLIGHT_SEARCHER_SERVICE"
|
||||
|
||||
private t = getI18n()
|
||||
|
||||
public searcherID = "interceptor"
|
||||
public searcherSectionTitle = this.t("settings.interceptor")
|
||||
|
||||
private readonly spotlight = this.bind(SpotlightService)
|
||||
private interceptorService = this.bind(InterceptorService)
|
||||
|
||||
override onServiceInit() {
|
||||
this.spotlight.registerSearcher(this)
|
||||
}
|
||||
|
||||
createSearchSession(
|
||||
query: Readonly<Ref<string>>
|
||||
): [Ref<SpotlightSearcherSessionState>, () => void] {
|
||||
const loading = ref(false)
|
||||
const results = ref<SpotlightSearcherResult[]>([])
|
||||
|
||||
const minisearch = new MiniSearch({
|
||||
fields: ["name", "alternates"],
|
||||
storeFields: ["name"],
|
||||
})
|
||||
|
||||
const interceptorSelection = this.interceptorService
|
||||
.currentInterceptorID as Ref<string>
|
||||
|
||||
const interceptors = this.interceptorService.availableInterceptors
|
||||
|
||||
minisearch.addAll(
|
||||
interceptors.value.map((entry) => {
|
||||
let id = `interceptor-${entry.interceptorID}`
|
||||
if (entry.interceptorID === interceptorSelection.value) {
|
||||
id += "-selected"
|
||||
}
|
||||
const name = unref(entry.name(this.t))
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
alternates: ["interceptor", "change", name],
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const scopeHandle = effectScope()
|
||||
|
||||
scopeHandle.run(() => {
|
||||
watch(
|
||||
[query],
|
||||
([query]) => {
|
||||
results.value = minisearch
|
||||
.search(query, {
|
||||
prefix: true,
|
||||
fuzzy: true,
|
||||
boost: {
|
||||
reltime: 2,
|
||||
},
|
||||
weights: {
|
||||
fuzzy: 0.2,
|
||||
prefix: 0.8,
|
||||
},
|
||||
})
|
||||
.map((x) => {
|
||||
return {
|
||||
id: x.id,
|
||||
icon: markRaw(
|
||||
x.id.endsWith("-selected") ? IconCheckCircle : IconCircle
|
||||
),
|
||||
score: x.score,
|
||||
text: {
|
||||
type: "text",
|
||||
text: [this.t("spotlight.section.interceptor"), x.name],
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
})
|
||||
|
||||
const onSessionEnd = () => {
|
||||
scopeHandle.stop()
|
||||
minisearch.removeAll()
|
||||
}
|
||||
|
||||
const resultObj = computed<SpotlightSearcherSessionState>(() => ({
|
||||
loading: loading.value,
|
||||
results: results.value,
|
||||
}))
|
||||
|
||||
return [resultObj, onSessionEnd]
|
||||
}
|
||||
|
||||
onResultSelect(result: SpotlightSearcherResult): void {
|
||||
const selectedInterceptor = result.id.split("-")[1]
|
||||
this.interceptorService.currentInterceptorID.value = selectedInterceptor
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue