feat: hoppscotch agent and agent interceptor (#4396)

Co-authored-by: CuriousCorrelation <CuriousCorrelation@gmail.com>
Co-authored-by: jamesgeorge007 <25279263+jamesgeorge007@users.noreply.github.com>
This commit is contained in:
Andrew Bastin
2024-10-03 20:26:30 +05:30
committed by GitHub
parent 0f27cf2d49
commit f75900ed30
106 changed files with 14636 additions and 609 deletions

View File

@@ -1,11 +1,11 @@
// generated by unplugin-vue-components
// We suggest you to commit this file into source control
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
import '@vue/runtime-core'
export {}
declare module '@vue/runtime-core' {
declare module 'vue' {
export interface GlobalComponents {
AccessTokens: typeof import('./components/accessTokens/index.vue')['default']
AccessTokensGenerateModal: typeof import('./components/accessTokens/GenerateModal.vue')['default']
@@ -174,7 +174,6 @@ declare module '@vue/runtime-core' {
IconLucideAlertTriangle: typeof import('~icons/lucide/alert-triangle')['default']
IconLucideArrowLeft: typeof import('~icons/lucide/arrow-left')['default']
IconLucideArrowUpRight: typeof import('~icons/lucide/arrow-up-right')['default']
IconLucideBrush: typeof import('~icons/lucide/brush')['default']
IconLucideCheckCircle: typeof import('~icons/lucide/check-circle')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
IconLucideGlobe: typeof import('~icons/lucide/globe')['default']
@@ -184,10 +183,8 @@ declare module '@vue/runtime-core' {
IconLucideLayers: typeof import('~icons/lucide/layers')['default']
IconLucideListEnd: typeof import('~icons/lucide/list-end')['default']
IconLucideMinus: typeof import('~icons/lucide/minus')['default']
IconLucideRss: typeof import('~icons/lucide/rss')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideUsers: typeof import('~icons/lucide/users')['default']
IconLucideVerified: typeof import('~icons/lucide/verified')['default']
IconLucideX: typeof import('~icons/lucide/x')['default']
ImportExportBase: typeof import('./components/importExport/Base.vue')['default']
ImportExportImportExportList: typeof import('./components/importExport/ImportExportList.vue')['default']
@@ -196,6 +193,10 @@ declare module '@vue/runtime-core' {
ImportExportImportExportStepsFileImport: typeof import('./components/importExport/ImportExportSteps/FileImport.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.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']
@@ -209,17 +210,14 @@ declare module '@vue/runtime-core' {
LensesRenderersVideoLensRenderer: typeof import('./components/lenses/renderers/VideoLensRenderer.vue')['default']
LensesRenderersXMLLensRenderer: typeof import('./components/lenses/renderers/XMLLensRenderer.vue')['default']
LensesResponseBodyRenderer: typeof import('./components/lenses/ResponseBodyRenderer.vue')['default']
ModalsNativeCACertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeCACertificates.vue')['default']
ModalsNativeClientCertificates: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertificates.vue')['default']
ModalsNativeClientCertsAdd: typeof import('./../../hoppscotch-selfhost-desktop/src/components/modals/NativeClientCertsAdd.vue')['default']
ProfileUserDelete: typeof import('./components/profile/UserDelete.vue')['default']
RealtimeCommunication: typeof import('./components/realtime/Communication.vue')['default']
RealtimeConnectionConfig: typeof import('./components/realtime/ConnectionConfig.vue')['default']
RealtimeLog: typeof import('./components/realtime/Log.vue')['default']
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsNativeInterceptor: typeof import('./../../hoppscotch-selfhost-desktop/src/components/settings/NativeInterceptor.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
@@ -246,5 +244,4 @@ declare module '@vue/runtime-core' {
WorkspaceCurrent: typeof import('./components/workspace/Current.vue')['default']
WorkspaceSelector: typeof import('./components/workspace/Selector.vue')['default']
}
}

View File

@@ -0,0 +1,153 @@
<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

@@ -0,0 +1,288 @@
<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

@@ -0,0 +1,146 @@
<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

@@ -0,0 +1,100 @@
<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

@@ -0,0 +1,114 @@
<template>
<div class="py-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle
:on="allowSSLVerification"
@change="allowSSLVerification = !allowSSLVerification"
/>
{{ t("agent.verify_ssl_certs") }}
</div>
<div class="flex space-x-4">
<!--
<HoppButtonSecondary
:icon="IconLucideFileBadge"
:label="'CA Certificates'"
outline
@click="showCACertificatesModal = true"
/>
-->
<!--
<HoppButtonSecondary
:icon="IconLucideFileKey"
:label="t('agent.client_certs')"
outline
@click="showClientCertificatesModal = true"
/>
-->
</div>
<!--
<ModalsNativeCACertificates
:show="showCACertificatesModal"
@hide-modal="showCACertificatesModal = false"
/>
-->
<!--
<InterceptorsAgentModalNativeClientCertificates
:show="showClientCertificatesModal"
@hide-modal="showClientCertificatesModal = false"
/>
-->
<div class="pt-4 space-y-4">
<div class="flex items-center">
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
{{ t("agent.use_http_proxy") }}
</div>
<HoppSmartInput
v-if="allowProxy"
v-model="proxyURL"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
/>
<p class="my-1 text-secondaryLight">
{{ t("agent.proxy_capabilities") }}
</p>
</div>
</div>
</template>
<!-- TODO: i18n -->
<script setup lang="ts">
import { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
// import IconLucideFileKey from "~icons/lucide/file-key"
import { useService } from "dioc/vue"
import {
RequestDef,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
import { syncRef } from "@vueuse/core"
type RequestProxyInfo = RequestDef["proxy"]
const t = useI18n()
const agentInterceptorService = useService(AgentInterceptorService)
const allowSSLVerification = agentInterceptorService.validateCerts
// const showCACertificatesModal = ref(false)
// const showClientCertificatesModal = ref(false)
const allowProxy = ref(false)
const proxyURL = ref("")
const proxyInfo = computed<RequestProxyInfo>({
get() {
if (allowProxy.value) {
return {
url: proxyURL.value,
}
}
return undefined
},
set(newData) {
if (newData) {
allowProxy.value = true
proxyURL.value = newData.url
} else {
allowProxy.value = false
}
},
})
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" })
</script>

View File

@@ -78,6 +78,7 @@ 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
/**
* Defines the arguments, if present for a given type that is required to be passed on

View File

@@ -38,7 +38,10 @@ export function getSuitableLenses(response: HoppRESTResponse): Lens[] {
)
return []
const contentType = response.headers.find((h) => h.key === "content-type")
// Lowercase the content-type key because HTTP Headers are case-insensitive by spec
const contentType = response.headers.find(
(h) => h.key.toLowerCase() === "content-type"
)
if (!contentType) return [rawLens]

View File

@@ -57,6 +57,14 @@
@hide-modal="showSupport = false"
/>
<AppOptions v-else :show="showSupport" @hide-modal="showSupport = false" />
<!-- Let additional stuff be registered -->
<template
v-for="(component, index) in rootExtensionComponents"
:key="index"
>
<component :is="component" />
</template>
</div>
</template>
@@ -78,6 +86,7 @@ import { platform } from "~/platform"
import { HoppSpotlightSessionEventData } from "~/platform/analytics"
import { PersistenceService } from "~/services/persistence"
import { SpotlightService } from "~/services/spotlight"
import { UIExtensionService } from "~/services/ui-extension.service"
const router = useRouter()
@@ -96,6 +105,9 @@ const t = useI18n()
const persistenceService = useService(PersistenceService)
const spotlightService = useService(SpotlightService)
const uiExtensionService = useService(UIExtensionService)
const rootExtensionComponents = uiExtensionService.rootUIExtensionComponents
const HAS_OPENED_SPOTLIGHT = useSetting("HAS_OPENED_SPOTLIGHT")

View File

@@ -42,10 +42,16 @@ export class ExtensionInspectorService extends Service implements Inspector {
() => currentExtensionStatus.value === "available"
)
const EXTENSIONS_ENABLED = computed(
() => this.interceptorService.currentInterceptorID.value === "extension"
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[] = []
@@ -56,9 +62,11 @@ export class ExtensionInspectorService extends Service implements Inspector {
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 &&
(!EXTENSIONS_ENABLED.value || !isExtensionInstalled.value)
!AGENT_ENABLED.value &&
(!EXTENSION_ENABLED.value || !isExtensionInstalled.value)
) {
let text
@@ -68,7 +76,7 @@ export class ExtensionInspectorService extends Service implements Inspector {
} else {
text = this.t("inspections.url.extension_not_installed")
}
} else if (!EXTENSIONS_ENABLED.value) {
} else if (!EXTENSION_ENABLED.value) {
text = this.t("inspections.url.extention_not_enabled")
} else {
text = this.t("inspections.url.localhost")

View File

@@ -0,0 +1,864 @@
import { CookieJarService } from "~/services/cookie-jar.service"
import {
Interceptor,
InterceptorError,
InterceptorService,
RequestRunResult,
} from "~/services/interceptor.service"
import { Service } from "dioc"
import { cloneDeep } from "lodash-es"
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"
import { base16 } from "@scure/base"
import { invokeAction } from "~/helpers/actions"
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]
export const preProcessRequest = (
req: AxiosRequestConfig
): AxiosRequestConfig => {
const reqClone = cloneDeep(req)
// If the parameters are URLSearchParams, inject them to URL instead
// This prevents issues of marshalling the URLSearchParams to the proxy
if (reqClone.params instanceof URLSearchParams) {
try {
const url = new URL(reqClone.url ?? "")
for (const [key, value] of reqClone.params.entries()) {
url.searchParams.append(key, value)
}
reqClone.url = url.toString()
} catch (e) {
// making this a non-empty block, so we can make the linter happy.
// we should probably use, allowEmptyCatch, or take the time to do something with the caught errors :)
}
reqClone.params = {}
}
return reqClone
}
async function processBody(
axiosReq: AxiosRequestConfig
): Promise<BodyDef | null> {
if (!axiosReq.data) return null
if (typeof axiosReq.data === "string") {
return { Text: axiosReq.data }
}
if (axiosReq.data instanceof FormData) {
const entries: FormDataEntry[] = []
for (const [key, value] of axiosReq.data.entries()) {
if (typeof value === "string") {
entries.push({
key,
value: { Text: value },
})
} else {
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 supportsCookies = true
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) {
console.log(e)
if (typeof e === "object" && (e as any)["RequestCancelled"]) {
return E.left("cancellation" as const)
}
// TODO: More in-depth error messages
return E.left(<InterceptorError>{
humanMessage: {
heading: (t) => t("error.network_fail"),
description: (t) => t("helpers.network_fail"),
},
})
}
})(),
}
}
}

View File

@@ -0,0 +1,80 @@
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

@@ -5,7 +5,10 @@ import { Service } from "dioc"
* supposed to be used only in development.
*
* This service logs events from the container and also events
* from all the services that are bound to the container.
* from all the services that are bound to the container. Along with that
* this service exposes all registered services (including this) to global
* scope (window) under the ID of the service so it can be accessed using
* the console for debugging.
*
* This service injects couple of utilities into the global scope:
* - `_getService(id: string): Service | undefined` - Returns the service instance with the given ID or undefined.
@@ -15,27 +18,30 @@ export class DebugService extends Service {
public static readonly ID = "DEBUG_SERVICE"
override onServiceInit() {
console.log("DebugService is initialized...")
console.debug("DebugService is initialized...")
const container = this.getContainer()
// Log container events
container.getEventStream().subscribe((event) => {
if (event.type === "SERVICE_BIND") {
console.log(
console.debug(
"[CONTAINER] Service Bind:",
event.bounderID ?? "<CONTAINER>",
"->",
event.boundeeID
)
} else if (event.type === "SERVICE_INIT") {
console.log("[CONTAINER] Service Init:", event.serviceID)
console.debug("[CONTAINER] Service Init:", event.serviceID)
// Subscribe to event stream of the newly initialized service
const service = container.getBoundServiceWithID(event.serviceID)
// Expose the service globally for debugging via a global variable
;(window as any)[event.serviceID] = service
service?.getEventStream().subscribe((ev: any) => {
console.log(`[${event.serviceID}] Event:`, ev)
console.debug(`[${event.serviceID}] Event:`, ev)
})
}
})
@@ -43,8 +49,11 @@ export class DebugService extends Service {
// Subscribe to event stream of all already bound services (if any)
for (const [id, service] of container.getBoundServices()) {
service.getEventStream().subscribe((event: any) => {
console.log(`[${id}]`, event)
console.debug(`[${id}]`, event)
})
// Expose the service globally for debugging via a global variable
;(window as any)[id] = service
}
// Inject debug utilities into the global scope

View File

@@ -0,0 +1,25 @@
import { Service } from "dioc"
import { Component, shallowRef } from "vue"
/**
* A registrar that allows other services to register
* additional stuff into the UI
*/
export class UIExtensionService extends Service {
public static readonly ID = "UI_EXTENSION_SERVICE"
/**
* Defines the Root UI Extensions that are registered.
* These components are rendered in `layouts/default.vue`.
* NOTE: This is supposed to be readonly, to register
*/
public rootUIExtensionComponents = shallowRef<Component[]>([])
/**
* Registers a root UI extension component that will be rendered
* in the root of the UI
*/
public addRootUIExtension(component: Component) {
this.rootUIExtensionComponents.value.push(component)
}
}