chore(common): deprecate legacy interceptor system (#5830)

This commit is contained in:
Shreyas 2026-02-21 14:22:47 +05:30 committed by GitHub
parent 1de672b8bd
commit 2e989cf242
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 23 additions and 3186 deletions

View file

@ -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']

View file

@ -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)

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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)
}
},
})

View file

@ -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
/**

View file

@ -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,
}
)
},
}

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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[]
}

View file

@ -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" },
})
})
})
})

View file

@ -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
})
}
}

View file

@ -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"),
},
})
}
})(),
}
}
}

View file

@ -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>

View file

@ -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),
}
}
}

View file

@ -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
}

View file

@ -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,
}
},
}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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()
})
})
})

View file

@ -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+$/, "")
}
}

View file

@ -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,
},
},
}

View file

@ -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(),

View file

@ -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
}
}