feat: platform independent core and the new desktop app (#4684)

* feat(desktop): init

* feat(desktop): external app download and setup

* feat(desktop): offload app load to plugin system

* perf(desktop): add rdbms facade and caching layer

* feat: parallelize signing, shared trust, lru cache

* feat: webapp encoder + compressor + hasher server

* feat(desktop): app autoupdate with hashed loader

* feat(kernel): init `hoppscotch-kernel`

* feat(kernel): `io`

* feat(kernel): `network`

* feat(kernel): `network` - native interceptor

* feat(kernel): `network` - interceptor - rest

* feat(kernel): `network` - interceptor - graphql

* feat(kernel): `network` - interceptor - capabilities

* feat(kernel): `network` - interceptor - `FormData`

* feat(kernel): `network` - interceptor - `oauth2.0`

* feat(kernel): `store`

* feat(desktop): dragging, traffic light, plugin workspaces

* feat(kernel|wip): `store`

* feat(kernel): `network` - capabilities - with active

* feat(kernel|wip): `network` - interceptor - `proxy`

* feat(kernel|wip): `network` - relay ext

* feat(kernel): `network` - interceptor - `proxy`

* feat(kernel): `network` - interceptor - decoding

* feat(kernel): `network` - interceptor - Kernel Err

* feat(kernel): `network` - flow transformation

* feat(kernel): `network` - request status

* fix(desktop): repositioning traffic lights on fullscreen exit

* feat(kernel): `network` - interceptor - `agent`

* feat(kernel): `store` - track updates

* feat(kernel): `network` - interceptor - extension

* feat(kernel): `network` - updates as overrides

* feat(interceptor): pre-process request encoding

* fix(ui): mismatched extension button size/position

* feat(kernel): `network` - interceptor - `browser`

* feat(native): common certs componsable

* fix(kernel): interceptor selection store and json parse

* feat(kernel): `network` - consistent multipart encoding

* feat(kernel): `network` - interceptor - `OAuth2.0`

* feat(kernel): `network` - interceptor - cookie support

* feat(agent): registration list, log-sink, relay

* feat(kernel): `network` - interceptor subtitles

* feat(kernel): `store` - persist network settings

* fix(agent): encrypted ser/de certificate requests

* feat(kernel): `kernelInterceptor` spotlight service

* fix(kernel): gql introspection edge-case schema

* ref: conditionals for migrated components

* feat(kernel): `localaccess` capability via relay

* feat(kernel): `network` - explicit types and lint

* feat(kernel): `store` - isolate host and platform

* feat(kernel): `store` - persistence service

* fix(infra): whitelisted origins, non-std engines

* feat(desktop): impl deep-link callbacks

* feat(kernel): `auth`

* feat(kernel): `io` - event listeners

* feat(kernel): platform migration

* fix: dep `vue` import on Win 11

Fixes `error TS2305: Module '"vue"' has no exported member
'VueConstructor'.` arising from `splitpane` dependency.

* fix(webapp-server): platform independent res paths

* feat(desktop): auth and emit via embedded server

* feat(platform): host, csp and bundle compatibility

- Bundle name format for using as host
- Windows UI handler HWND casting and version detection
- CSP headers type handling in URI protocol
- Protocol whitelist in env config

* feat(desktop|wip): login flow with `auth-tokens`

feat(desktop|wip): typesafe auth

* feat(backend): `auth` token flow, gql/websocket

feat(desktop): working auth for gql

feat: gql client with refresh token

* feat(backend): `auth` token flow, authorization bearer

* fix(gen): qualifier clash when invalidating cache

* feat(common): coordinated initialization service

* fix(desktop): appload persistence in data json

* feat(desktop|wip): desktop icons and updater

* fix: typos in readme docs

* fix: docker ignore copying on windows

* fix: update `.lock` file after rebase

* fix: `persistenceService` setup in tests

* fix: remove old console logs

* fix: console error on invalid schema

Show console error if default value is used when loading invalid data from
local storage

* fix(test): `PersistenceService` methods

* fix(test): `PersistenceService` rest tab state

* fix(test): `PersistenceService` gql tab state

* fix(test): `PersistenceService` global env

* fix(test): `PersistenceService` mqtt request

* fix(test): `PersistenceService` sse request

* fix(test): `PersistenceService` socketio request

* fix(test): `PersistenceService` websocket request

* fix(test): `PersistenceService` secret environment

* fix(test): `PersistenceService` selected env

* fix(test): `PersistenceService` collections

* fix(test): `PersistenceService` environments

* fix(test): `PersistenceService` history

* fix(test): `PersistenceService` settings

* fix(test): `PersistenceService` migrations

* fix(test): `InspectionService` request inspector

* feat(desktop): button to clear bundle/key cache

This is useful when there are partial updates to the web app or bundle gen server
which haven't been correctly propagated when the app bundle was downloaded.

If the user were to change the self host instance without updating the
desktop app; which is possible albeit rarely under very certain circumstances,
desktop app will refuse to load the bundle, this is because the desktop app
cannot differentiate between partial updates vs incorrect bundle being hosted
since both will fail verification.

The button lets the user decide what should be the appropriate action,
clear the bundle and trust the hosted app
or make sure the app is built and hosted correctly.

* fix(desktop): enforce one version per instance

This was part of a leftover scaffolding from development.

* fix(desktop): bundle url not stored after download

* fix(desktop): stalling progress on updates

* fix(backend): helper to parse cookie into kv-pairs

* feat(desktop): launch session on working endpoints

* fix(common): preserve `auth` structure and default

* fix: loading native networking with kernel mode

* fix: fallback for unhandled response error

* fix: `urlencoded` content request processing

* feat: `interceptor` - error mapping for `browser`

* fix: backwards compatibility for `digest` auth

* fix: platform check for `initializationService`

* fix: `interceptor` - analytics `strategy` resolution

* fix: `interceptor` - check for `cookies` component

* fix: enable digest auth support for `native`

* test: `interceptor` - kernel interceptor

* fix(relay): `grantType` casing for OAuth2.0

* test(wip): kernel transformers

* fix(relay): auth headers discarding others

* fix(desktop): http version deserialization

* fix(common): `grantType` extractor, auth processor

* fix: `PersistenceService` - parsing edge cases

* fix(infra): post rebase fixup

* fix(web): component structure and lint

* fix(desktop): cohesive splash opener, scroll url section

* fix: explicit auto auth and docs on url auth

* fix(relay): special chars failing proxy auth

* fix: finer cert control setting option

* fix: post-rebase fixup

* feat(appload): ability to vendor pre-built bytes

* fix: avoid copying over `target` dir in containers

* fix: auth key missing in capability set

* fix(desktop): relax `refresh_token` requirement

This is to support Firebase token

* fix(desktop): normalization for Windows WebView

* feat(desktop): instance switcher and vendored app

* fix(desktop): merge artifacts and conflicts

* feat(desktop): instance switcher improvements

* fix: derive instance name from normalized name

* fix: pkg links, lints and UI edge cases

* feat(desktop): restore window state after relaunch

* fix(desktop): distinguish header for cloud/default

* fix: instance switcher in web mode

* fix: close dropdown on new instance modal

* fix: whitelist vendored app origin

* feat(desktop): platform parity - `collections`

* fix: history entries population desync

* fix(desktop): check for history storage status

* fix(desktop): safe parse `globalEnv`

* feat(desktop): platform parity - `environment`

* fix: use settings store for proxy url

* fix: lint, unused imports

* fix: proxy input enabled for other interceptors

* feat: reverse proxy for desktop app server

* fix: duplicate entries after connecting to sh

* fix: specify instance org qualified

* fix: remove debugging logs

* feat(desktop): enable `devtools` in release builds

* fix(desktop): prepend protocol validation edgecase

* feat(desktop): clear cache on removing instance

* fix: better response toast message

* fix: avoid reverse proxy for webapp server

* fix(desktop): ignore subpath in instance name

* feat: switcher ui/ux improvements

* feat: more switcher ui/ux improvements

* feat(server): specify bundle version at build time

* fix(desktop): missing migration as rebase artifact

* fix: minor switcher ui/ux improvement

* fix: rebase artifacts

* fix: consolidated toast on success

* fix: missing i18n strings

* fix(desktop): handle drag and drop fe side

* feat: confirmation modal on instance removal

* chore: minor UI update

* chore: minor UI changes

* fix: gql connection partial refactor

* fix: resolve merge artifacts

* chore: prod lint

* feat(desktop): better desktop app update ux

* fix: broken gql connection.ts

---------

Co-authored-by: nivedin <nivedinp@gmail.com>
Co-authored-by: Andrew Bastin <andrewbastin.k@gmail.com>
This commit is contained in:
Shreyas 2025-02-28 00:01:25 +05:30 committed by GitHub
parent 3563e1eb16
commit a6147f4ce4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
465 changed files with 54061 additions and 4097 deletions

View file

@ -1,2 +1,35 @@
.devenv*
.direnv
.devcontainer
.git
.github
.husky
.vscode
.envrc
devenv.yaml
devenv.nix
.prettierrc.js
.prettierignore
.editorconfig
.npmrc
.firebaserc
node_modules node_modules
**/node_modules
**/*/node_modules **/*/node_modules
**/dist
**/build
**/target
**/__tests__
**/*.test.*
**/coverage
*.md
LICENSE
CODEOWNERS
.DS_Store
*.log

View file

@ -20,7 +20,14 @@ DATA_ENCRYPTION_KEY="data encryption key with 32 char"
# Hoppscotch App Domain Config # Hoppscotch App Domain Config
REDIRECT_URL="http://localhost:3000" REDIRECT_URL="http://localhost:3000"
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100" # Whitelisted origins for the Hoppscotch App.
# This list controls which origins can interact with the app through cross-origin comms.
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
# - app://localhost_3200: Bundle server origin identifier
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
# bundle names like `app://{bundle-name}/`
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100,app://localhost_3200,app://hoppscotch"
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
# Google Auth Config # Google Auth Config

3
.envrc Normal file
View file

@ -0,0 +1,3 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
use devenv

10
.gitignore vendored
View file

@ -172,3 +172,13 @@ tests/*/videos
# GQL SDL generated for the frontends # GQL SDL generated for the frontends
gql-gen/ gql-gen/
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View file

@ -21,6 +21,11 @@
reverse_proxy localhost:8080 reverse_proxy localhost:8080
} }
# Handle requests under `/desktop-app-server*` path
handle_path /desktop-app-server* {
reverse_proxy localhost:3200
}
# Catch-all route for unknown paths, serves `selfhost-web` SPA # Catch-all route for unknown paths, serves `selfhost-web` SPA
handle { handle {
root * /site/selfhost-web root * /site/selfhost-web

View file

@ -52,6 +52,7 @@ fs.rmSync("build.env")
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile' const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy") const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/main.js"], "Backend Server") const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/main.js"], "Backend Server")
const webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server")
caddyProcess.on("exit", (code) => { caddyProcess.on("exit", (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`) console.log(`Exiting process because Caddy Server exited with code ${code}`)
@ -63,11 +64,17 @@ backendProcess.on("exit", (code) => {
process.exit(code) process.exit(code)
}) })
webappProcess.on("exit", (code) => {
console.log(`Exiting process because Webapp Server exited with code ${code}`)
process.exit(code)
})
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log("SIGINT received, exiting...") console.log("SIGINT received, exiting...")
caddyProcess.kill("SIGINT") caddyProcess.kill("SIGINT")
backendProcess.kill("SIGINT") backendProcess.kill("SIGINT")
webappProcess.kill("SIGINT")
process.exit(0) process.exit(0)
}) })

140
devenv.lock Normal file
View file

@ -0,0 +1,140 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1738772960,
"owner": "cachix",
"repo": "devenv",
"rev": "7f756cdf3fbb01cab243dcec4de0ca94e6aaa2af",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1738737274,
"owner": "nix-community",
"repo": "fenix",
"rev": "f82de9980822f3b1efcf54944939b1d514386827",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1737465171,
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "9364dc02281ce2d37a1f55b6e51f7c0f65a75f17",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1738734093,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5b2753b0356d1c951d7a3ef1d086ba5a71fff43c",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"fenix": "fenix",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"pre-commit-hooks": [
"git-hooks"
]
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1738754241,
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "ca47cddc31ae76a05e8709ed4aec805c5ef741d3",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

189
devenv.nix Normal file
View file

@ -0,0 +1,189 @@
{ pkgs, lib, config, inputs, ... }:
let
rosettaPkgs =
if pkgs.stdenv.isDarwin && pkgs.stdenv.isAarch64
then pkgs.pkgsx86_64Darwin
else pkgs;
darwinPackages = with pkgs; [
darwin.apple_sdk.frameworks.Security
darwin.apple_sdk.frameworks.CoreServices
darwin.apple_sdk.frameworks.CoreFoundation
darwin.apple_sdk.frameworks.Foundation
darwin.apple_sdk.frameworks.AppKit
darwin.apple_sdk.frameworks.WebKit
];
linuxPackages = with pkgs; [
libsoup_3
webkitgtk_4_1
librsvg
libappindicator
libayatana-appindicator
];
in {
packages = with pkgs; [
git
lima
colima
docker
jq
# NOTE: In case there's `Cannot find module: ... bcrypt ...` error, try `npm rebuild bcrypt`
# See: https://github.com/kelektiv/node.bcrypt.js/issues/800
# See: https://github.com/kelektiv/node.bcrypt.js/issues/1055
nodejs_20
nodePackages.typescript-language-server
nodePackages."@volar/vue-language-server"
nodePackages.prisma
prisma-engines
cargo-edit
] ++ lib.optionals pkgs.stdenv.isDarwin darwinPackages
++ lib.optionals pkgs.stdenv.isLinux linuxPackages;
env = {
APP_GREET = "Hoppscotch";
DATABASE_URL = "postgresql://postgres:testpass@localhost:5432/hoppscotch?connect_timeout=300";
DOCKER_BUILDKIT = "1";
COMPOSE_DOCKER_CLI_BUILD = "1";
} // lib.optionalAttrs pkgs.stdenv.isLinux {
# NOTE: Setting these `PRISMA_*` environment variable fixes
# "Error: Failed to fetch sha256 checksum at https://binaries.prisma.sh/all_commits/<hash>/linux-nixos/libquery_engine.so.node.gz.sha256 - 404 Not Found"
# See: https://github.com/prisma/prisma/discussions/3120
PRISMA_QUERY_ENGINE_LIBRARY = "${pkgs.prisma-engines}/lib/libquery_engine.node";
PRISMA_QUERY_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/query-engine";
PRISMA_SCHEMA_ENGINE_BINARY = "${pkgs.prisma-engines}/bin/schema-engine";
LD_LIBRARY_PATH = lib.makeLibraryPath [
pkgs.libappindicator
pkgs.libayatana-appindicator
];
} // lib.optionalAttrs pkgs.stdenv.isDarwin {
# Place to put macOS-specific environment variables
};
scripts = {
hello.exec = "echo hello from $APP_GREET";
e.exec = "emacs";
lima-setup.exec = "limactl start template://docker";
lima-clean.exec = "limactl rm -f $(limactl ls -q)";
colima-start.exec = "colima start --cpu 4 --memory 50";
docker-prune.exec = ''
echo "Cleaning up unused Docker resources..."
docker system prune -f
'';
docker-logs.exec = "docker logs -f hoppscotch-aio";
docker-status.exec = ''
echo "Container Status:"
docker ps -a --filter "name=hoppscotch-aio" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
'';
db-reset.exec = ''
echo "Resetting database..."
psql -U postgres -d hoppscotch -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
echo "Database reset complete."
'';
docker-clean-all.exec = ''
echo "Starting complete Docker cleanup..."
echo "1/6: Stopping all running containers..."
CONTAINERS=$(docker ps -q)
if [ -n "$CONTAINERS" ]; then
docker stop $CONTAINERS
echo " Containers stopped"
else
echo " No running containers found"
fi
echo "2/6: Removing all containers..."
CONTAINERS=$(docker ps -aq)
if [ -n "$CONTAINERS" ]; then
docker rm $CONTAINERS
echo " Containers removed"
else
echo " No containers to remove"
fi
echo "3/6: Removing all images..."
IMAGES=$(docker images -q)
if [ -n "$IMAGES" ]; then
docker rmi --force $IMAGES
echo " Images removed"
else
echo " No images to remove"
fi
echo "4/6: Removing all volumes..."
VOLUMES=$(docker volume ls -q)
if [ -n "$VOLUMES" ]; then
docker volume rm $VOLUMES
echo " Volumes removed"
else
echo " No volumes to remove"
fi
echo "5/6: Removing custom networks..."
NETWORKS=$(docker network ls --filter type=custom -q)
if [ -n "$NETWORKS" ]; then
docker network rm $NETWORKS
echo " Networks removed"
else
echo " No custom networks to remove"
fi
echo "6/6: Running system prune..."
docker system prune --all --force --volumes
echo " System pruned"
echo "Done!"
'';
};
enterShell = ''
git --version
echo "Hoppscotch development environment ready!"
${lib.optionalString pkgs.stdenv.isDarwin ''
# Place to put macOS-specific shell initialization
''}
${lib.optionalString pkgs.stdenv.isLinux ''
# Place to put Linux-specific shell initialization
''}
'';
enterTest = ''
echo "Running tests"
'';
dotenv.enable = true;
languages = {
typescript = {
enable = true;
};
javascript = {
package = pkgs.nodejs_20;
enable = true;
npm.enable = true;
pnpm.enable = true;
};
rust = {
enable = true;
channel = "nightly";
components = [
"rustc"
"cargo"
"clippy"
"rustfmt"
"rust-analyzer"
"llvm-tools-preview"
"rust-src"
"rustc-codegen-cranelift-preview"
];
};
};
}

View file

@ -2,9 +2,36 @@
# has a container with a Postgres instance running. # has a container with a Postgres instance running.
# You can tweak around this file to match your instances # You can tweak around this file to match your instances
# PROFILES EXPLANATION:
#
# We use Docker Compose profiles to manage different deployment scenarios and avoid port conflicts.
#
# These are all the available profiles:
# - default: All-in-one service + database + auto-migration (recommended for most users)
# - default-no-db: All-in-one service without database (for users with external DB)
# - backend: The backend service only
# - app: The main Hoppscotch application only
# - admin: The self-host admin dashboard only
# - webapp: The static web app server only
# - database: Just the PostgreSQL database
# - just-backend: All services except webapp for local development
# - deprecated: All deprecated services (not recommended)
# USAGE:
#
# To run the default setup: docker compose --profile default up
# To run without database: docker compose --profile default-no-db up
# To run specific components: docker compose --profile backend up
# To run all except webapp: docker compose --profile just-backend up
# To run deprecated services: docker compose --profile deprecated up
# NOTE: The default and default-no-db profiles should not be mixed with individual service
# profiles as they would conflict on ports.
services: services:
# This service runs the backend app in the port 3170 # This service runs the backend app in the port 3170
hoppscotch-backend: hoppscotch-backend:
profiles: ["backend", "just-backend"]
container_name: hoppscotch-backend container_name: hoppscotch-backend
build: build:
dockerfile: prod.Dockerfile dockerfile: prod.Dockerfile
@ -32,6 +59,7 @@ services:
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile # the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
hoppscotch-app: hoppscotch-app:
profiles: ["app"]
container_name: hoppscotch-app container_name: hoppscotch-app
build: build:
dockerfile: prod.Dockerfile dockerfile: prod.Dockerfile
@ -49,6 +77,7 @@ services:
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for # NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile # the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
hoppscotch-sh-admin: hoppscotch-sh-admin:
profiles: ["admin"]
container_name: hoppscotch-sh-admin container_name: hoppscotch-sh-admin
build: build:
dockerfile: prod.Dockerfile dockerfile: prod.Dockerfile
@ -62,8 +91,22 @@ services:
- "3280:80" - "3280:80"
- "3100:3100" - "3100:3100"
# The service that spins up all 3 services at once in one container # The static server for serving web content to desktop shell, hosted at port 3200
hoppscotch-webapp-server:
profiles: ["webapp"]
container_name: hoppscotch-webapp-server
env_file:
- ./.env
build:
dockerfile: prod.Dockerfile
context: .
target: webapp_server
ports:
- "3200:3200"
# The service that spins up all services at once in one container
hoppscotch-aio: hoppscotch-aio:
profiles: ["default", "default-no-db"]
container_name: hoppscotch-aio container_name: hoppscotch-aio
restart: unless-stopped restart: unless-stopped
build: build:
@ -79,12 +122,14 @@ services:
- "3000:3000" - "3000:3000"
- "3100:3100" - "3100:3100"
- "3170:3170" - "3170:3170"
- "3200:3200"
- "3080:80" - "3080:80"
# The preset DB service, you can delete/comment the below lines if # The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance # you are using an external postgres instance
# This will be exposed at port 5432 # This will be exposed at port 5432
hoppscotch-db: hoppscotch-db:
profiles: ["default", "database", "just-backend"]
image: postgres:15 image: postgres:15
ports: ports:
- "5432:5432" - "5432:5432"
@ -105,8 +150,24 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
# All the services listed below are deprececated # Auto-migration service - handles database migrations automatically
hoppscotch-migrate:
profiles: ["default", "just-backend"]
build:
dockerfile: prod.Dockerfile
context: .
target: backend
env_file:
- ./.env
depends_on:
hoppscotch-db:
condition: service_healthy
command: sh -c "pnpx prisma migrate deploy"
# All the services listed below are deprecated
# These services are kept for backward compatibility but should not be used for new deployments
hoppscotch-old-backend: hoppscotch-old-backend:
profiles: ["deprecated"]
container_name: hoppscotch-old-backend container_name: hoppscotch-old-backend
build: build:
dockerfile: packages/hoppscotch-backend/Dockerfile dockerfile: packages/hoppscotch-backend/Dockerfile
@ -130,6 +191,7 @@ services:
- "3170:3000" - "3170:3000"
hoppscotch-old-app: hoppscotch-old-app:
profiles: ["deprecated"]
container_name: hoppscotch-old-app container_name: hoppscotch-old-app
build: build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
@ -142,6 +204,7 @@ services:
- "3000:8080" - "3000:8080"
hoppscotch-old-sh-admin: hoppscotch-old-sh-admin:
profiles: ["deprecated"]
container_name: hoppscotch-old-sh-admin container_name: hoppscotch-old-sh-admin
build: build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile dockerfile: packages/hoppscotch-sh-admin/Dockerfile
@ -152,3 +215,29 @@ services:
- hoppscotch-old-backend - hoppscotch-old-backend
ports: ports:
- "3100:8080" - "3100:8080"
# DEPLOYMENT SCENARIOS:
# 1. Default deployment (recommended):
# docker compose --profile default up
# This will start: AIO + database + auto-migration
#
# 2. Default deployment without database:
# docker compose --profile default-no-db up
# This will start: AIO only (use when you have an external database)
#
# 3. Individual service deployment:
# docker compose --profile backend up # Just the backend
# docker compose --profile app up # Just the app
# docker compose --profile admin up # Just the admin dashboard
# docker compose --profile webapp up # Just the static web server
# docker compose --profile database up # Just the database
#
# 4. Development deployment:
# docker compose --profile just-backend up # All services except webapp
#
# 5. Deprecated services:
# docker compose --profile deprecated up
# This will start all deprecated services (not recommended for new deployments)
#
# Remember: The default and default-no-db profiles should not be mixed with individual service
# profiles as they would conflict on ports.

View file

@ -7,6 +7,7 @@
"declaration": true, "declaration": true,
"declarationDir": "./dist", "declarationDir": "./dist",
"moduleResolution": "node", "moduleResolution": "node",
"skipLibCheck": true,
"allowJs": true "allowJs": true
}, },
"include": ["src/*"] "include": ["src/*"]

View file

@ -1,3 +0,0 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

View file

@ -16,6 +16,7 @@
"@vueuse/core": "^11.1.0", "@vueuse/core": "^11.1.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"fp-ts": "^2.16.9", "fp-ts": "^2.16.9",
"lodash-es": "4.17.21",
"vue": "3.3.9" "vue": "3.3.9"
}, },
"devDependencies": { "devDependencies": {
@ -29,6 +30,7 @@
"typescript": "^5.6.3", "typescript": "^5.6.3",
"unplugin-icons": "^0.19.3", "unplugin-icons": "^0.19.3",
"vite": "^5.4.8", "vite": "^5.4.8",
"@types/lodash-es": "4.17.12",
"vue-tsc": "^2.1.6" "vue-tsc": "^2.1.6"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package] [package]
name = "hoppscotch-agent" name = "hoppscotch-agent"
version = "0.1.3" version = "0.1.4"
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration." description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
authors = ["AndrewBastin", "CuriousCorrelation"] authors = ["AndrewBastin", "CuriousCorrelation"]
edition = "2021" edition = "2021"
@ -29,9 +29,10 @@ tokio-util = "0.7.12"
uuid = { version = "1.11.0", features = [ "v4", "fast-rng" ] } uuid = { version = "1.11.0", features = [ "v4", "fast-rng" ] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
rand = "0.8.5" rand = "0.8.5"
log = "0.4.22" tracing = "0.1.40"
env_logger = "0.11.5" tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "fmt", "std", "time"] }
hoppscotch-relay = { path = "../../hoppscotch-relay" } tracing-appender = "0.2.3"
relay = { git = "https://github.com/CuriousCorrelation/relay.git" }
thiserror = "1.0.64" thiserror = "1.0.64"
tauri-plugin-store = "2.1.0" tauri-plugin-store = "2.1.0"
x25519-dalek = { version = "2.0.1", features = ["getrandom"] } x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
@ -43,14 +44,12 @@ lazy_static = "1.5.0"
tauri-plugin-single-instance = "2.0.1" tauri-plugin-single-instance = "2.0.1"
tauri-plugin-http = { version = "2.0.1", features = ["gzip"] } tauri-plugin-http = { version = "2.0.1", features = ["gzip"] }
native-dialog = "0.7.0" native-dialog = "0.7.0"
sha2 = "0.10.8"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
tempfile = { version = "3.13.0" } tempfile = { version = "3.13.0" }
winreg = { version = "0.52.0" } winreg = { version = "0.52.0" }
[dev-dependencies]
mockito = "1.5.0"
[features] [features]
default = ["tauri-plugin-autostart"] default = ["tauri-plugin-autostart"]
portable = [] portable = []

View file

@ -18,6 +18,7 @@
"core:default", "core:default",
"shell:allow-open", "shell:allow-open",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-set-focus", "core:window:allow-set-focus",
"core:window:allow-set-always-on-top" "core:window:allow-set-always-on-top"
] ]

View file

@ -0,0 +1,40 @@
use std::sync::Arc;
use crate::{
model::{MaskedRegistration, RegistrationsList},
state::AppState,
util::generate_auth_key_hash,
};
#[tauri::command]
#[tracing::instrument(skip(state))]
pub async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
tracing::debug!("Retrieving current OTP");
let otp = state.active_registration_code.read().await.clone();
if otp.is_some() {
tracing::debug!("OTP found");
} else {
tracing::debug!("No active OTP");
}
Ok(otp)
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn list_registrations(state: tauri::State<'_, Arc<AppState>>) -> Result<RegistrationsList, ()> {
tracing::debug!("Retrieving registrations list");
let masked_registrations = state
.get_registrations()
.iter()
.map(|entry| MaskedRegistration {
registered_at: entry.value().registered_at,
auth_key_hash: generate_auth_key_hash(entry.key()),
})
.collect();
Ok(RegistrationsList {
registrations: masked_registrations,
total: state.get_registrations().len(),
})
}

View file

@ -1,3 +1,5 @@
use std::sync::Arc;
use axum::{ use axum::{
body::Bytes, body::Bytes,
extract::{Path, State}, extract::{Path, State},
@ -8,46 +10,57 @@ use axum_extra::{
headers::{authorization::Bearer, Authorization}, headers::{authorization::Bearer, Authorization},
TypedHeader, TypedHeader,
}; };
use hoppscotch_relay::{RequestWithMetadata, ResponseWithMetadata}; use chrono::Utc;
use std::sync::Arc; use rand::Rng;
use serde_json::json;
use tauri::{AppHandle, Emitter}; use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use x25519_dalek::{EphemeralSecret, PublicKey}; use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{ use crate::{
error::{AgentError, AgentResult}, error::{AgentError, AgentResult},
model::{AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse}, global::NONCE,
state::{AppState, Registration}, model::{
util::EncryptedJson, AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, LogEntry, LogLevel,
MaskedRegistration, Registration,
},
state::AppState,
util::{generate_auth_key_hash, EncryptedJson},
}; };
use chrono::Utc;
use rand::Rng;
use serde_json::json;
use uuid::Uuid;
#[tracing::instrument]
fn generate_otp() -> String { fn generate_otp() -> String {
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000); let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
let formatted = format!("{:06}", otp);
format!("{:06}", otp) tracing::debug!("Generated OTP: {}", formatted);
formatted
} }
#[tracing::instrument(skip(app_handle))]
pub async fn handshake( pub async fn handshake(
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>, State((_, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<HandshakeResponse>> { ) -> AgentResult<Json<HandshakeResponse>> {
Ok(Json(HandshakeResponse { tracing::info!("Processing handshake request");
let response = HandshakeResponse {
status: "success".to_string(), status: "success".to_string(),
__hoppscotch__agent__: true, __hoppscotch__agent__: true,
agent_version: app_handle.package_info().version.to_string(), agent_version: app_handle.package_info().version.to_string(),
})) };
tracing::info!("Handshake successful");
Ok(Json(response))
} }
#[tracing::instrument(skip(state, app_handle))]
pub async fn receive_registration( pub async fn receive_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>, State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<serde_json::Value>> { ) -> AgentResult<Json<serde_json::Value>> {
let otp = generate_otp(); let otp = generate_otp();
tracing::info!("Generated new registration OTP");
let mut active_registration_code = state.active_registration_code.write().await; let mut active_registration_code = state.active_registration_code.write().await;
if !active_registration_code.is_none() { if !active_registration_code.is_none() {
tracing::warn!("Registration attempt while another registration is active");
return Ok(Json( return Ok(Json(
json!({ "message": "There is already an existing registration happening" }), json!({ "message": "There is already an existing registration happening" }),
)); ));
@ -55,32 +68,73 @@ pub async fn receive_registration(
*active_registration_code = Some(otp.clone()); *active_registration_code = Some(otp.clone());
app_handle match app_handle.emit("registration-received", otp) {
.emit("registration_received", otp) Ok(_) => {
.map_err(|_| AgentError::InternalServerError)?; tracing::info!("Registration event emitted successfully");
Ok(Json( Ok(Json(
json!({ "message": "Registration received and stored" }), json!({ "message": "Registration received and stored" }),
)) ))
}
Err(e) => {
tracing::error!("Failed to emit registration event: {}", e);
Err(AgentError::InternalServerError)
}
}
} }
#[tracing::instrument(skip(state, _app_handle))]
pub async fn registration(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
) -> AgentResult<EncryptedJson<MaskedRegistration>> {
let token = auth_header.token();
if !state.validate_access(token) {
tracing::warn!("Unauthorized attempt to list registrations");
return Err(AgentError::Unauthorized);
}
let registration = state
.get_registration(token)
.ok_or(AgentError::Unauthorized)?;
let key_b16 = registration.shared_secret_b16;
let registration = MaskedRegistration {
registered_at: registration.registered_at,
auth_key_hash: generate_auth_key_hash(token),
};
tracing::info!("Successfully retrieved registrations list");
Ok(EncryptedJson {
key_b16,
data: registration,
})
}
#[tracing::instrument(skip(state, app_handle), fields(auth_key))]
pub async fn verify_registration( pub async fn verify_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>, State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>, Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
) -> AgentResult<Json<AuthKeyResponse>> { ) -> AgentResult<Json<AuthKeyResponse>> {
state tracing::info!("Verifying registration request");
if !state
.validate_registration(&confirmed_registration.registration) .validate_registration(&confirmed_registration.registration)
.await .await
.then_some(()) {
.ok_or(AgentError::InvalidRegistration)?; tracing::warn!("Invalid registration attempt");
return Err(AgentError::InvalidRegistration);
}
let auth_key = Uuid::new_v4().to_string(); let auth_key = Uuid::new_v4().to_string();
let created_at = Utc::now(); let created_at = Utc::now();
let auth_key_copy = auth_key.clone(); tracing::Span::current().record("auth_key", &auth_key.as_str());
let agent_secret_key = EphemeralSecret::random(); let auth_key_copy = auth_key.clone();
let agent_public_key = PublicKey::from(&agent_secret_key); let secret_key = EphemeralSecret::random();
let public_key = PublicKey::from(&secret_key);
let their_public_key = { let their_public_key = {
let public_key_slice: &[u8; 32] = let public_key_slice: &[u8; 32] =
@ -92,9 +146,9 @@ pub async fn verify_registration(
PublicKey::from(public_key_slice.to_owned()) PublicKey::from(public_key_slice.to_owned())
}; };
let shared_secret = agent_secret_key.diffie_hellman(&their_public_key); let shared_secret = secret_key.diffie_hellman(&their_public_key);
let _ = state.update_registrations(app_handle.clone(), |regs| { if let Err(e) = state.update_registrations(app_handle.clone(), |regs| {
regs.insert( regs.insert(
auth_key_copy, auth_key_copy,
Registration { Registration {
@ -102,82 +156,102 @@ pub async fn verify_registration(
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()), shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()),
}, },
); );
})?; }) {
tracing::error!("Failed to update registrations: {:?}", e);
return Err(e);
}
let auth_payload = json!({ let auth_payload = json!({
"auth_key": auth_key, "auth_key": auth_key,
"created_at": created_at "created_at": created_at
}); });
app_handle if let Err(e) = app_handle.emit("authenticated", &auth_payload) {
.emit("authenticated", &auth_payload) tracing::error!("Failed to emit authenticated event: {:?}", e);
.map_err(|_| AgentError::InternalServerError)?; return Err(AgentError::InternalServerError);
}
let _ = state.clear_active_registration().await;
tracing::info!("Registration verified successfully");
Ok(Json(AuthKeyResponse { Ok(Json(AuthKeyResponse {
auth_key, auth_key,
created_at, created_at,
agent_public_key_b16: base16::encode_lower(agent_public_key.as_bytes()), agent_public_key_b16: base16::encode_lower(public_key.as_bytes()),
})) }))
} }
pub async fn run_request<T>( #[tracing::instrument(skip(state, app_handle), fields(auth_key = %auth_key))]
State((state, _app_handle)): State<(Arc<AppState>, T)>, pub async fn delete_registration(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(auth_key): Path<String>,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized deletion attempt");
return Err(AgentError::Unauthorized);
}
let _removed = state.update_registrations(app_handle.clone(), |regs| {
regs.remove(&auth_key);
})?;
tracing::info!("Registration deleted successfully");
let message = format!("{} registration deleted successfully", auth_key);
Ok(Json(json!({ "message": message })))
}
#[tracing::instrument(skip(state, body, _app_handle), fields(req_id))]
pub async fn execute(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>, TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> AgentResult<EncryptedJson<ResponseWithMetadata>> { ) -> AgentResult<EncryptedJson<relay::Response>> {
let nonce = headers let nonce = match headers.get(NONCE) {
.get("X-Hopp-Nonce") Some(n) => match n.to_str() {
.ok_or(AgentError::Unauthorized)? Ok(n) => n,
.to_str() Err(_) => {
.map_err(|_| AgentError::Unauthorized)?; tracing::warn!("Invalid nonce header");
return Err(AgentError::Unauthorized);
let req: RequestWithMetadata = state
.validate_access_and_get_data(auth_header.token(), nonce, &body)
.ok_or(AgentError::Unauthorized)?;
let req_id = req.req_id;
let reg_info = state
.get_registration_info(auth_header.token())
.ok_or(AgentError::Unauthorized)?;
let cancel_token = tokio_util::sync::CancellationToken::new();
state.add_cancellation_token(req.req_id, cancel_token.clone());
let cancel_token_clone = cancel_token.clone();
// Execute the HTTP request in a blocking thread pool and handles cancellation.
//
// It:
// 1. Uses `spawn_blocking` to run the sync `run_request_task`
// without blocking the main Tokio runtime.
// 2. Uses `select!` to concurrently wait for either
// a. the task to complete,
// b. or a cancellation signal.
//
// Why spawn_blocking?
// - `run_request_task` uses synchronous curl operations which would block
// the async runtime if not run in a separate thread.
// - `spawn_blocking` moves this operation to a thread pool designed for
// blocking tasks, so other async operations to continue unblocked.
let result = tokio::select! {
res = tokio::task::spawn_blocking(move || hoppscotch_relay::run_request_task(&req, cancel_token_clone)) => {
match res {
Ok(task_result) => Ok(task_result?),
Err(_) => Err(AgentError::InternalServerError),
} }
}, },
_ = cancel_token.cancelled() => { None => {
Err(AgentError::RequestCancelled) tracing::warn!("Missing nonce header");
return Err(AgentError::Unauthorized);
} }
}; };
state.remove_cancellation_token(req_id); let request = match state.validate_access_and_get_data::<relay::Request>(
auth_header.token(),
nonce,
&body,
) {
Some(r) => r,
None => {
tracing::warn!("Invalid access or data");
return Err(AgentError::Unauthorized);
}
};
result.map(|val| EncryptedJson { let request_id = request.id;
tracing::Span::current().record("request_id", &request_id);
let reg_info = match state.get_registration(auth_header.token()) {
Some(r) => r,
None => {
tracing::warn!("Registration info not found");
return Err(AgentError::Unauthorized);
}
};
Ok(relay::execute(request)
.await
.map(|response| EncryptedJson {
key_b16: reg_info.shared_secret_b16, key_b16: reg_info.shared_secret_b16,
data: val, data: response,
}) })?)
} }
/// Provides a way for registered clients to check if their /// Provides a way for registered clients to check if their
@ -187,34 +261,139 @@ pub async fn run_request<T>(
/// registration, the client also needs the shared secret to verify /// registration, the client also needs the shared secret to verify
/// if the read fails, or the auth_key didn't validate and this route returns /// if the read fails, or the auth_key didn't validate and this route returns
/// undefined, we can count on the registration not being valid anymore. /// undefined, we can count on the registration not being valid anymore.
#[tracing::instrument(skip(state, _app_handle))]
pub async fn registered_handshake( pub async fn registered_handshake(
State((state, _)): State<(Arc<AppState>, AppHandle)>, State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>, TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
) -> AgentResult<EncryptedJson<serde_json::Value>> { ) -> AgentResult<EncryptedJson<serde_json::Value>> {
let reg_info = state.get_registration_info(auth_header.token()); let reg_info = state.get_registration(auth_header.token());
match reg_info { match reg_info {
Some(reg) => Ok(EncryptedJson { Some(reg) => {
tracing::info!("Handshake successful");
Ok(EncryptedJson {
key_b16: reg.shared_secret_b16, key_b16: reg.shared_secret_b16,
data: json!(true), data: json!(true),
}), })
None => Err(AgentError::Unauthorized), }
None => {
tracing::warn!("Unauthorized handshake attempt");
Err(AgentError::Unauthorized)
}
} }
} }
pub async fn cancel_request<T>( #[tracing::instrument(skip(state, _app_handle), fields(request_id = %request_id))]
State((state, _app_handle)): State<(Arc<AppState>, T)>, pub async fn cancel(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>, TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(req_id): Path<usize>, Path(request_id): Path<usize>,
) -> AgentResult<Json<serde_json::Value>> { ) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) { if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized cancellation attempt");
return Err(AgentError::Unauthorized); return Err(AgentError::Unauthorized);
} }
if let Some((_, token)) = state.remove_cancellation_token(req_id) { if let Ok(()) = relay::cancel(request_id.try_into().unwrap()).await {
token.cancel(); tracing::info!("Request cancelled successfully");
Ok(Json(json!({"message": "Request cancelled successfully"}))) Ok(Json(json!({"message": "Request cancelled successfully"})))
} else { } else {
tracing::warn!("Request not found");
Err(AgentError::RequestNotFound) Err(AgentError::RequestNotFound)
} }
} }
#[tracing::instrument(skip_all)]
pub async fn log_sink(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
headers: HeaderMap,
body: Bytes,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized log sink access attempt");
return Err(AgentError::Unauthorized);
}
let nonce = match headers.get(NONCE) {
Some(n) => match n.to_str() {
Ok(n) => n,
Err(_) => {
tracing::warn!("Invalid nonce header");
return Err(AgentError::Unauthorized);
}
},
None => {
tracing::warn!("Missing nonce header");
return Err(AgentError::Unauthorized);
}
};
let log_entry: LogEntry =
match state.validate_access_and_get_data(auth_header.token(), nonce, &body) {
Some(entry) => entry,
None => {
tracing::warn!("Failed to decrypt or parse log entry");
return Err(AgentError::BadRequest("Invalid log entry format".into()));
}
};
let metadata_str = log_entry
.metadata
.map(|m| m.to_string())
.unwrap_or_default();
let correlation = log_entry.correlation_id.unwrap_or_default();
match log_entry.level {
LogLevel::Debug => {
tracing::debug!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Info => {
tracing::info!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Warn => {
tracing::warn!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
LogLevel::Error => {
tracing::error!(
timestamp = %log_entry.timestamp,
context = %log_entry.context,
source = %log_entry.source,
metadata = %metadata_str,
correlation_id = %correlation,
"{}",
log_entry.message
);
}
}
Ok(Json(json!({
"status": "success",
"message": "Log entry processed"
})))
}

View file

@ -10,13 +10,13 @@ pub fn panic(msg: &str) {
.show_alert() .show_alert()
.unwrap_or_default(); .unwrap_or_default();
log::error!("{}: {}", FATAL_ERROR, msg); tracing::error!("{}: {}", FATAL_ERROR, msg);
panic!("{}: {}", FATAL_ERROR, msg); panic!("{}: {}", FATAL_ERROR, msg);
} }
pub fn info(msg: &str) { pub fn info(msg: &str) {
log::info!("{}", msg); tracing::info!("{}", msg);
MessageDialog::new() MessageDialog::new()
.set_type(MessageType::Info) .set_type(MessageType::Info)
@ -27,7 +27,7 @@ pub fn info(msg: &str) {
} }
pub fn warn(msg: &str) { pub fn warn(msg: &str) {
log::warn!("{}", msg); tracing::warn!("{}", msg);
MessageDialog::new() MessageDialog::new()
.set_type(MessageType::Warning) .set_type(MessageType::Warning)
@ -38,7 +38,7 @@ pub fn warn(msg: &str) {
} }
pub fn error(msg: &str) { pub fn error(msg: &str) {
log::error!("{}", msg); tracing::error!("{}", msg);
MessageDialog::new() MessageDialog::new()
.set_type(MessageType::Error) .set_type(MessageType::Error)

View file

@ -8,6 +8,10 @@ use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum AgentError { pub enum AgentError {
#[error("FATAL: No `main` window found")]
NoMainWindow,
#[error("Tauri error: {0}")]
Tauri(#[from] tauri::Error),
#[error("Invalid Registration")] #[error("Invalid Registration")]
InvalidRegistration, InvalidRegistration,
#[error("Invalid Client Public Key")] #[error("Invalid Client Public Key")]
@ -45,7 +49,13 @@ pub enum AgentError {
#[error("Store error: {0}")] #[error("Store error: {0}")]
TauriPluginStore(#[from] tauri_plugin_store::Error), TauriPluginStore(#[from] tauri_plugin_store::Error),
#[error("Relay error: {0}")] #[error("Relay error: {0}")]
Relay(#[from] hoppscotch_relay::RelayError), Relay(#[from] relay::error::RelayError),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Log init error: {0}")]
LogInit(#[from] tracing_appender::rolling::InitError),
#[error("Log init global error: {0}")]
LogInitGlobal(#[from] tracing::subscriber::SetGlobalDefaultError),
} }
impl IntoResponse for AgentError { impl IntoResponse for AgentError {
@ -55,7 +65,9 @@ impl IntoResponse for AgentError {
AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()), AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()), AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()), AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
AgentError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), AgentError::InternalServerError => {
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
}
AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()), AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()), AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),

View file

@ -1,2 +1,3 @@
pub const AGENT_STORE: &str = "app_data.bin"; pub const AGENT_STORE: &str = "app_data.bin";
pub const REGISTRATIONS: &str = "registrations"; pub const REGISTRATIONS: &str = "registrations";
pub const NONCE: &str = "X-Hopp-Nonce";

View file

@ -1,3 +1,4 @@
pub mod command;
pub mod controller; pub mod controller;
pub mod dialog; pub mod dialog;
pub mod error; pub mod error;
@ -11,59 +12,105 @@ pub mod updater;
pub mod util; pub mod util;
pub mod webview; pub mod webview;
use log::{error, info};
use std::sync::Arc; use std::sync::Arc;
use tauri::{Emitter, Listener, Manager, WebviewWindowBuilder}; use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindowBuilder};
use tauri_plugin_updater::UpdaterExt; use tauri_plugin_updater::UpdaterExt;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing_subscriber::{fmt::format::JsonFields, EnvFilter};
use model::Payload; use error::{AgentError, AgentResult};
use model::{LogGuard, Payload};
use state::AppState; use state::AppState;
#[tauri::command] #[tracing::instrument(skip(app_handle))]
async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> { fn create_main_window(app_handle: &AppHandle) -> AgentResult<()> {
Ok(state.active_registration_code.read().await.clone()) tracing::info!("Creating main application window");
let main = &app_handle
.config()
.app
.windows
.first()
.ok_or(AgentError::NoMainWindow)?;
tracing::debug!("Building webview window from config");
let window = WebviewWindowBuilder::from_config(app_handle, main)?.build()?;
window.hide()?;
tracing::info!("Main window created successfully");
Ok(())
}
#[tracing::instrument(skip(app_handle))]
pub fn show_main_window(app_handle: &AppHandle) -> AgentResult<()> {
tracing::debug!("Attempting to show main window");
if let Some(window) = app_handle.get_webview_window("main") {
window.show()?;
window.set_focus()?;
tracing::info!("Main window shown and focused");
}
Ok(())
}
#[tracing::instrument(skip(app_handle))]
pub fn hide_main_window(app_handle: &AppHandle) -> AgentResult<()> {
tracing::debug!("Attempting to hide main window");
if let Some(window) = app_handle.get_webview_window("main") {
window.hide()?;
tracing::info!("Main window hidden");
}
Ok(())
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
env_logger::init(); tracing::info!("Initializing Hoppscotch Agent");
// The installer takes care of installing `WebView`, // The installer takes care of installing `WebView`,
// this check is only required for portable variant. // this check is only required for portable variant.
#[cfg(all(feature = "portable", windows))] #[cfg(all(feature = "portable", windows))]
{
tracing::debug!("Checking WebView initialization for portable Windows variant");
webview::init_webview(); webview::init_webview();
}
let cancellation_token = CancellationToken::new(); let cancellation_token = CancellationToken::new();
let server_cancellation_token = cancellation_token.clone(); let server_cancellation_token = cancellation_token.clone();
tauri::Builder::default() tracing::debug!("Building Tauri application");
let builder = tauri::Builder::default()
// NOTE: Currently, plugins run in the order they were added in to the builder, // NOTE: Currently, plugins run in the order they were added in to the builder,
// so `tauri_plugin_single_instance` needs to be registered first. // so `tauri_plugin_single_instance` needs to be registered first.
// See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance // See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { .plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
info!("{}, {args:?}, {cwd}", app.package_info().name); tracing::info!(
app_name = %app.package_info().name,
"Single instance handler triggered"
);
app.emit("single-instance", Payload::new(args, cwd)) if let Err(e) = app.emit("single-instance", Payload::new(args, cwd)) {
.unwrap(); tracing::error!(error = %e, "Failed to emit single-instance event");
}
// Application is already running, bring it to foreground. // Application is already running, bring it to foreground.
if let Some(window) = app.get_webview_window("main") { if let Err(e) = show_main_window(&app) {
let _ = window.show(); tracing::error!(error = %e, "Failed to show window");
let _ = window.set_focus();
} else {
error!("Failed to get `main` window");
} }
})) }))
.plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_store::Builder::new().build())
.setup(move |app| { .setup(move |app| {
let app_handle = app.app_handle(); // let _ = setup_logging(&app.handle())?;
tracing::info!("Setting up application");
let app_handle = app.handle();
#[cfg(all(desktop, not(feature = "portable")))] #[cfg(all(desktop, not(feature = "portable")))]
{ {
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_autostart::ManagerExt; use tauri_plugin_autostart::ManagerExt;
tracing::debug!("Configuring autostart for desktop variant");
let _ = app.handle().plugin(tauri_plugin_autostart::init( let _ = app.handle().plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent, MacosLauncher::LaunchAgent,
None, None,
@ -71,30 +118,29 @@ pub fn run() {
let autostart_manager = app.autolaunch(); let autostart_manager = app.autolaunch();
println!( tracing::info!(
"autostart enabled: {}", enabled = autostart_manager.is_enabled().unwrap_or(false),
autostart_manager.is_enabled().unwrap() "Checking autostart status"
); );
if !autostart_manager.is_enabled().unwrap() { if !autostart_manager.is_enabled().unwrap_or(false) {
let _ = autostart_manager.enable(); if let Err(e) = autostart_manager.enable() {
println!( tracing::error!(error = %e, "Failed to enable autostart");
"autostart updated: {}", } else {
autostart_manager.is_enabled().unwrap() tracing::info!("Autostart enabled successfully");
); }
} }
}; };
#[cfg(desktop)] #[cfg(desktop)]
{ {
tracing::debug!("Initializing desktop-specific features");
let _ = app let _ = app
.handle() .handle()
.plugin(tauri_plugin_updater::Builder::new().build()); .plugin(tauri_plugin_updater::Builder::new().build());
let _ = app.handle().plugin(tauri_plugin_dialog::init()); let _ = app.handle().plugin(tauri_plugin_dialog::init());
let updater = app.updater_builder().build().unwrap(); let updater = app.updater_builder().build().unwrap();
let app_handle_ref = app_handle.clone(); let app_handle_ref = app_handle.clone();
tauri::async_runtime::spawn_blocking(|| { tauri::async_runtime::spawn_blocking(|| {
@ -104,20 +150,24 @@ pub fn run() {
}); });
}; };
let app_state = Arc::new(AppState::new(app_handle.clone())?); // Create and hide the main window during setup.
create_main_window(&app_handle)?;
tracing::debug!("Initializing application state");
let app_state = Arc::new(AppState::new(app_handle.clone())?);
app.manage(app_state.clone()); app.manage(app_state.clone());
let server_cancellation_token = server_cancellation_token.clone(); let server_cancellation_token = server_cancellation_token.clone();
let server_app_handle = app_handle.clone(); let server_app_handle = app_handle.clone();
tracing::debug!("Spawning server process");
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
server::run_server(app_state, server_cancellation_token, server_app_handle).await; server::run_server(app_state, server_cancellation_token, server_app_handle).await;
}); });
#[cfg(all(desktop))] #[cfg(all(desktop))]
{ {
tracing::debug!("Creating system tray");
let handle = app.handle(); let handle = app.handle();
tray::create_tray(handle)?; tray::create_tray(handle)?;
} }
@ -125,50 +175,68 @@ pub fn run() {
// Blocks the app from populating the macOS dock // Blocks the app from populating the macOS dock
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
tracing::debug!("Setting macOS activation policy");
app_handle app_handle
.set_activation_policy(tauri::ActivationPolicy::Accessory) .set_activation_policy(tauri::ActivationPolicy::Accessory)
.unwrap(); .unwrap();
}; };
let app_handle_ref = app_handle.clone(); let app_handle_ref = app_handle.clone();
app_handle.listen("registration-received", move |_| {
app_handle.listen("registration_received", move |_| { tracing::info!("Registration received event triggered");
WebviewWindowBuilder::from_config( if let Err(e) = show_main_window(&app_handle_ref) {
&app_handle_ref, tracing::error!(error = %e, "Failed to show window");
&app_handle_ref.config().app.windows[0], }
)
.unwrap()
.build()
.unwrap()
.show()
.unwrap();
}); });
tracing::info!("Application setup completed successfully");
Ok(()) Ok(())
}) })
.manage(cancellation_token) .manage(cancellation_token)
.on_window_event(|window, event| { .on_window_event(|window, event| {
match &event { match &event {
tauri::WindowEvent::CloseRequested { .. } => { tauri::WindowEvent::CloseRequested { api, .. } => {
tracing::info!("Window close requested");
api.prevent_close();
if let Err(e) = window.hide() {
tracing::error!(error = %e, "Failed to hide window");
}
let app_state = window.state::<Arc<AppState>>(); let app_state = window.state::<Arc<AppState>>();
let mut current_code = app_state.active_registration_code.blocking_write(); let mut current_code = app_state.active_registration_code.blocking_write();
if current_code.is_some() { if current_code.is_some() {
tracing::debug!("Clearing active registration code");
*current_code = None; *current_code = None;
} }
if let Err(e) = window.emit("window-hidden", ()) {
tracing::error!(error = %e, "Failed to emit window-hidden event");
}
}
_ => {
tracing::debug!(event = ?event, "Window event received");
} }
_ => {}
}; };
}) })
.invoke_handler(tauri::generate_handler![get_otp]) .invoke_handler(tauri::generate_handler![
command::get_otp,
command::list_registrations
]);
tracing::info!("Building Tauri application with context");
let app = builder
.build(tauri::generate_context!()) .build(tauri::generate_context!())
.expect("error while building tauri application") .expect("error while building tauri application");
.run(|app_handle, event| match event {
tracing::info!("Running application");
app.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, code, .. } => { tauri::RunEvent::ExitRequested { api, code, .. } => {
if code.is_none() || matches!(code, Some(0)) { if code.is_none() || matches!(code, Some(0)) {
api.prevent_exit() tracing::info!("Exit requested, preventing immediate exit");
api.prevent_exit();
} else if code.is_some() { } else if code.is_some() {
tracing::info!("Exit with non-zero code requested, initiating shutdown");
let state = app_handle.state::<CancellationToken>(); let state = app_handle.state::<CancellationToken>();
state.cancel(); state.cancel();
} }
@ -176,3 +244,47 @@ pub fn run() {
_ => {} _ => {}
}); });
} }
#[tracing::instrument(skip(app_handle))]
pub fn setup_logging(app_handle: &AppHandle) -> AgentResult<()> {
tracing::info!("Setting up logging system");
let app_data_dir = app_handle.path().app_data_dir()?;
tracing::debug!(path = ?app_data_dir, "Creating app data directory");
std::fs::create_dir_all(&app_data_dir)?;
tracing::debug!("Configuring file appender");
let file_appender = tracing_appender::rolling::RollingFileAppender::builder()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("hoppscotch-agent")
.filename_suffix("log")
.max_log_files(1)
.build(&app_data_dir)?;
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing::debug!("Building subscriber with JSON formatting");
let subscriber = tracing_subscriber::fmt()
.fmt_fields(JsonFields::new())
.with_target(false)
.with_writer(non_blocking)
.with_ansi(false)
.with_timer(tracing_subscriber::fmt::time::UtcTime::rfc_3339())
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if cfg!(debug_assertions) {
"debug"
} else {
"info"
}
.into()
}))
.with_filter_reloading()
.finish();
tracing::subscriber::set_global_default(subscriber)?;
app_handle.manage(LogGuard(_guard));
tracing::info!("Logging system initialized successfully");
Ok(())
}

View file

@ -1,6 +1,18 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!! // Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn main() { fn main() {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),
)
.with(tracing_subscriber::fmt::layer().without_time())
.init();
tracing::info!("Starting Hoppscotch Agent...");
hoppscotch_agent_lib::run() hoppscotch_agent_lib::run()
} }

View file

@ -1,5 +1,42 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
/// Describes one registered app instance
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registration {
pub registered_at: DateTime<Utc>,
/// base16 (lowercase) encoded shared secret that the client
/// and agent established during registration that is used
/// to encrypt traffic between them
pub shared_secret_b16: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct MaskedRegistration {
pub registered_at: DateTime<Utc>,
pub auth_key_hash: String,
}
impl From<(&String, &Registration)> for MaskedRegistration {
fn from((key, registration): (&String, &Registration)) -> Self {
let hash = Sha256::digest(key.as_bytes());
let short_hash = base16::encode_lower(&hash[..3]);
Self {
registered_at: registration.registered_at,
auth_key_hash: short_hash,
}
}
}
#[derive(Debug, Serialize)]
pub struct RegistrationsList {
pub registrations: Vec<MaskedRegistration>,
pub total: usize,
}
/// Single instance payload. /// Single instance payload.
#[derive(Clone, Serialize)] #[derive(Clone, Serialize)]
@ -45,3 +82,29 @@ pub struct AuthKeyResponse {
/// and client after registration /// and client after registration
pub agent_public_key_b16: String, pub agent_public_key_b16: String,
} }
/// A logger guard, managed by tauri runtime to make sure
/// logger doesn't get cleaned up or dropped during app's run time.
pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard);
#[derive(Debug, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: LogLevel,
pub context: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
pub source: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub correlation_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "UPPERCASE")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}

View file

@ -1,5 +1,5 @@
use axum::{ use axum::{
routing::{get, post}, routing::{delete, get, post},
Router, Router,
}; };
use std::sync::Arc; use std::sync::Arc;
@ -22,7 +22,13 @@ pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
"/registered-handshake", "/registered-handshake",
get(controller::registered_handshake), get(controller::registered_handshake),
) )
.route("/request", post(controller::run_request)) .route("/registration", get(controller::registration))
.route("/cancel-request/:req_id", post(controller::cancel_request)) .route(
"/registrations/:auth_key",
delete(controller::delete_registration),
)
.route("/execute", post(controller::execute))
.route("/cancel/:req_id", post(controller::cancel))
.route("/log-sink", post(controller::log_sink))
.with_state((state, app_handle)) .with_state((state, app_handle))
} }

View file

@ -6,11 +6,13 @@ use tower_http::cors::CorsLayer;
use crate::route; use crate::route;
use crate::state::AppState; use crate::state::AppState;
#[tracing::instrument(skip(state, cancellation_token, app_handle))]
pub async fn run_server( pub async fn run_server(
state: Arc<AppState>, state: Arc<AppState>,
cancellation_token: CancellationToken, cancellation_token: CancellationToken,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) { ) {
tracing::info!("Initializing server");
let cors = CorsLayer::permissive(); let cors = CorsLayer::permissive();
let app = Router::new() let app = Router::new()
@ -18,17 +20,31 @@ pub async fn run_server(
.layer(cors); .layer(cors);
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119)); let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
tracing::info!(address = %addr, "Starting server");
println!("Server running on http://{}", addr); match tokio::net::TcpListener::bind(&addr).await {
Ok(listener) => {
tracing::info!(address = %addr, "Server bound successfully");
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); if let Err(e) = axum::serve(listener, app.into_make_service())
axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(async move { .with_graceful_shutdown(async move {
cancellation_token.cancelled().await; cancellation_token.cancelled().await;
tracing::info!("Graceful shutdown initiated");
}) })
.await .await
.unwrap(); {
tracing::error!(error = %e, "Server error occurred");
return;
}
println!("Server shut down"); tracing::info!("Server shut down successfully");
}
Err(e) => {
tracing::error!(
error = %e,
address = %addr,
"Failed to bind server to address"
);
}
}
} }

View file

@ -1,8 +1,7 @@
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit}; use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
use axum::body::Bytes; use axum::body::Bytes;
use chrono::{DateTime, Utc};
use dashmap::DashMap; use dashmap::DashMap;
use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde::de::DeserializeOwned;
use tauri_plugin_store::StoreExt; use tauri_plugin_store::StoreExt;
use tokio::sync::RwLock; use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@ -10,20 +9,10 @@ use tokio_util::sync::CancellationToken;
use crate::{ use crate::{
error::{AgentError, AgentResult}, error::{AgentError, AgentResult},
global::{AGENT_STORE, REGISTRATIONS}, global::{AGENT_STORE, REGISTRATIONS},
model::Registration,
}; };
/// Describes one registered app instance #[derive(Debug, Default)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registration {
pub registered_at: DateTime<Utc>,
/// base16 (lowercase) encoded shared secret that the client
/// and agent established during registration that is used
/// to encrypt traffic between them
pub shared_secret_b16: String,
}
#[derive(Default)]
pub struct AppState { pub struct AppState {
/// The active registration code that is being registered. /// The active registration code that is being registered.
pub active_registration_code: RwLock<Option<String>>, pub active_registration_code: RwLock<Option<String>>,
@ -37,19 +26,36 @@ pub struct AppState {
} }
impl AppState { impl AppState {
#[tracing::instrument(skip(app_handle))]
pub fn new(app_handle: tauri::AppHandle) -> AgentResult<Self> { pub fn new(app_handle: tauri::AppHandle) -> AgentResult<Self> {
let store = app_handle.store(AGENT_STORE)?; tracing::info!("Initializing application state");
let store = match app_handle.store(AGENT_STORE) {
Ok(store) => store,
Err(e) => {
tracing::error!("Failed to access app store: {}", e);
return Err(e.into());
}
};
// Try loading and parsing registrations from the store, if that failed, // Try loading and parsing registrations from the store, if that failed,
// load the default list // load the default list
let registrations = store let registrations = store
.get(REGISTRATIONS) .get(REGISTRATIONS)
.and_then(|val| serde_json::from_value(val.clone()).ok()) .and_then(|val| serde_json::from_value(val.clone()).ok())
.unwrap_or_else(|| DashMap::new()); .unwrap_or_else(|| {
tracing::debug!("No existing registrations found, initializing empty map");
DashMap::new()
});
// Try to save the latest registrations list // Try to save the latest registrations list
let _ = store.set(REGISTRATIONS, serde_json::to_value(&registrations)?); let _ = store.set(REGISTRATIONS, serde_json::to_value(&registrations)?);
let _ = store.save();
if let Err(e) = store.save() {
tracing::error!("Failed to persist store changes: {}", e);
return Err(e.into());
}
tracing::info!("Application state initialized successfully");
Ok(Self { Ok(Self {
active_registration_code: RwLock::new(None), active_registration_code: RwLock::new(None),
@ -62,7 +68,9 @@ impl AppState {
/// NOTE: Although DashMap API allows you to update the list from an immutable /// NOTE: Although DashMap API allows you to update the list from an immutable
/// reference, you shouldn't do it for registrations as `update_registrations` /// reference, you shouldn't do it for registrations as `update_registrations`
/// performs save operation that needs to be done and should be used instead /// performs save operation that needs to be done and should be used instead
#[tracing::instrument]
pub fn get_registrations(&self) -> &DashMap<String, Registration> { pub fn get_registrations(&self) -> &DashMap<String, Registration> {
tracing::debug!("Retrieving registrations list");
&self.registrations &self.registrations
} }
@ -71,60 +79,117 @@ impl AppState {
/// This function bypasses `store.reload()` to avoid issues from stale or inconsistent /// This function bypasses `store.reload()` to avoid issues from stale or inconsistent
/// data on disk. By relying solely on the in-memory `self.registrations`, /// data on disk. By relying solely on the in-memory `self.registrations`,
/// we make sure that updates are applied based on the most recent changes in memory. /// we make sure that updates are applied based on the most recent changes in memory.
#[tracing::instrument(skip(self, app_handle, update_func))]
pub fn update_registrations( pub fn update_registrations(
&self, &self,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
update_func: impl FnOnce(&DashMap<String, Registration>), update_func: impl FnOnce(&DashMap<String, Registration>),
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
tracing::info!("Updating registrations");
update_func(&self.registrations); update_func(&self.registrations);
let store = app_handle.store(AGENT_STORE)?; let store = match app_handle.store(AGENT_STORE) {
Ok(store) => store,
Err(e) => {
tracing::error!("Failed to access app store: {}", e);
return Err(e.into());
}
};
if store.has(REGISTRATIONS) { if store.has(REGISTRATIONS) {
tracing::debug!("Clearing existing registrations from store");
// We've confirmed `REGISTRATIONS` exists in the store // We've confirmed `REGISTRATIONS` exists in the store
store if !store.delete(REGISTRATIONS) {
.delete(REGISTRATIONS) tracing::error!("Failed to clear existing registrations");
.then_some(()) return Err(AgentError::RegistrationClearError);
.ok_or(AgentError::RegistrationClearError)?; }
} else { } else {
log::debug!("`REGISTRATIONS` key not found in store; continuing with update."); tracing::debug!("`REGISTRATIONS` key not found in store; continuing with update.");
} }
// Since we've established `self.registrations` as the source of truth, // Since we've established `self.registrations` as the source of truth,
// we avoid reloading the store from disk and instead choose to override it. // we avoid reloading the store from disk and instead choose to override it.
match serde_json::to_value(self.registrations.clone()) {
store.set( Ok(value) => {
REGISTRATIONS, let _ = store.set(REGISTRATIONS, value);
serde_json::to_value(self.registrations.clone())?, }
); Err(e) => {
tracing::error!("Failed to serialize registrations: {}", e);
return Err(e.into());
}
}
// Explicitly save the changes // Explicitly save the changes
store.save()?; if let Err(e) = store.save() {
tracing::error!("Failed to persist store changes: {}", e);
return Err(e.into());
}
tracing::info!("Registrations updated successfully");
Ok(()) Ok(())
} }
/// Clear all the registrations /// Clear all the registrations
#[tracing::instrument(skip(self, app_handle))]
pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> { pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> {
Ok(self.update_registrations(app_handle, |registrations| registrations.clear())?) tracing::info!("Clearing all registrations");
self.update_registrations(app_handle, |registrations| registrations.clear())?;
tracing::info!("All registrations cleared successfully");
Ok(())
} }
#[tracing::instrument(skip(self))]
pub async fn clear_active_registration(&self) {
tracing::debug!("Clearing active registration code");
let mut active_registration_code = self.active_registration_code.write().await;
*active_registration_code = None;
tracing::debug!("Active registration code cleared");
}
#[tracing::instrument(skip(self))]
pub async fn validate_registration(&self, registration: &str) -> bool { pub async fn validate_registration(&self, registration: &str) -> bool {
self.active_registration_code.read().await.as_deref() == Some(registration) tracing::debug!("Validating registration code");
let is_valid = self.active_registration_code.read().await.as_deref() == Some(registration);
if is_valid {
tracing::info!("Registration code validated successfully");
} else {
tracing::warn!("Invalid registration code provided");
}
is_valid
} }
#[tracing::instrument(skip(self))]
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> { pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
self.cancellation_tokens.remove(&req_id) tracing::debug!(req_id, "Removing cancellation token");
let result = self.cancellation_tokens.remove(&req_id);
if result.is_some() {
tracing::info!(req_id, "Cancellation token removed successfully");
} else {
tracing::debug!(req_id, "No cancellation token found to remove");
}
result
} }
pub fn add_cancellation_token(&self, req_id: usize, cancellation_tokens: CancellationToken) { #[tracing::instrument(skip(self))]
self.cancellation_tokens.insert(req_id, cancellation_tokens); pub fn add_cancellation_token(&self, req_id: usize, cancellation_token: CancellationToken) {
tracing::debug!(req_id, "Adding new cancellation token");
self.cancellation_tokens.insert(req_id, cancellation_token);
tracing::debug!(req_id, "Cancellation token added successfully");
} }
#[tracing::instrument(skip(self))]
pub fn validate_access(&self, auth_key: &str) -> bool { pub fn validate_access(&self, auth_key: &str) -> bool {
self.registrations.get(auth_key).is_some() tracing::debug!(auth_key, "Validating access");
let is_valid = self.registrations.get(auth_key).is_some();
if is_valid {
tracing::info!(auth_key, "Access validated successfully");
} else {
tracing::warn!(auth_key, "Invalid access attempt");
}
is_valid
} }
#[tracing::instrument(skip(self, data))]
pub fn validate_access_and_get_data<T>( pub fn validate_access_and_get_data<T>(
&self, &self,
auth_key: &str, auth_key: &str,
@ -134,28 +199,79 @@ impl AppState {
where where
T: DeserializeOwned, T: DeserializeOwned,
{ {
if let Some(registration) = self.registrations.get(auth_key) { tracing::debug!(
let key: [u8; 32] = base16::decode(&registration.shared_secret_b16).ok()?[0..32] auth_key,
.try_into() nonce_len = nonce.len(),
.ok()?; "Validating access and decrypting data"
);
let nonce: [u8; 12] = base16::decode(nonce).ok()?[0..12].try_into().ok()?; let registration = match self.registrations.get(auth_key) {
Some(reg) => reg,
None => {
tracing::warn!(auth_key, "Registration not found");
return None;
}
};
let key: [u8; 32] = match base16::decode(&registration.shared_secret_b16).ok()?[0..32]
.try_into()
.ok()
{
Some(k) => k,
None => {
tracing::error!(auth_key, "Failed to decode shared secret");
return None;
}
};
let nonce: [u8; 12] = match base16::decode(nonce).ok()?[0..12].try_into().ok() {
Some(n) => n,
None => {
tracing::error!(auth_key, "Failed to decode nonce");
return None;
}
};
let cipher = Aes256Gcm::new(&key.into()); let cipher = Aes256Gcm::new(&key.into());
let data = data.iter().cloned().collect::<Vec<u8>>(); let data = data.iter().cloned().collect::<Vec<u8>>();
let plain_data = cipher.decrypt(&nonce.into(), data.as_slice()).ok()?; let plain_data = match cipher.decrypt(&nonce.into(), data.as_slice()) {
Ok(d) => d,
Err(e) => {
tracing::error!(auth_key, error = ?e, "Decryption failed");
return None;
}
};
serde_json::from_reader(plain_data.as_slice()).ok() match serde_json::from_reader(plain_data.as_slice()) {
} else { Ok(result) => {
tracing::info!(auth_key, "Data successfully decrypted and parsed");
Some(result)
}
Err(e) => {
tracing::error!(auth_key, error = ?e, "Failed to parse decrypted data");
None None
} }
} }
}
pub fn get_registration_info(&self, auth_key: &str) -> Option<Registration> { #[tracing::instrument(skip(self))]
self.registrations pub fn get_registration(&self, auth_key: &str) -> Option<Registration> {
tracing::debug!(auth_key, "Retrieving registration tracing::info");
let result = self
.registrations
.get(auth_key) .get(auth_key)
.map(|reference| reference.value().clone()) .map(|reference| reference.value().clone());
if result.is_some() {
tracing::info!(
auth_key,
"Registration tracing::info retrieved successfully"
);
} else {
tracing::debug!(auth_key, "No registration tracing::info found");
}
result
} }
} }

View file

@ -1,11 +1,11 @@
use crate::state::AppState; use crate::{show_main_window, state::AppState};
use lazy_static::lazy_static; use lazy_static::lazy_static;
use std::sync::Arc; use std::sync::Arc;
use tauri::{ use tauri::{
image::Image, image::Image,
menu::{MenuBuilder, MenuItem}, menu::{MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager, AppHandle, Emitter, Manager,
}; };
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png"); const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
@ -23,6 +23,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
true, true,
None::<&str>, None::<&str>,
)?; )?;
let show_registrations = MenuItem::with_id(
app,
"show_registrations",
"Show Registrations",
true,
None::<&str>,
)?;
let pkg_info = app.package_info(); let pkg_info = app.package_info();
let app_name = pkg_info.name.clone(); let app_name = pkg_info.name.clone();
@ -42,6 +49,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.item(&app_version_item) .item(&app_version_item)
.separator() .separator()
.item(&clear_registrations) .item(&clear_registrations)
.item(&show_registrations)
.separator()
.item(&quit_i) .item(&quit_i)
.build()?; .build()?;
@ -57,8 +66,9 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.menu_on_left_click(true) .menu_on_left_click(true)
.on_menu_event(move |app, event| match event.id.as_ref() { .on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => { "quit" => {
log::info!("Exiting the agent..."); tracing::info!("Exiting the agent...");
app.exit(-1); // Exit with a specific code to allow actual exit.
app.exit(1);
} }
"clear_registrations" => { "clear_registrations" => {
let app_state = app.state::<Arc<AppState>>(); let app_state = app.state::<Arc<AppState>>();
@ -67,8 +77,16 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.clear_registrations(app.clone()) .clear_registrations(app.clone())
.expect("Invariant violation: Failed to clear registrations"); .expect("Invariant violation: Failed to clear registrations");
} }
"show_registrations" => {
app.emit("show-registrations", ()).unwrap_or_else(|e| {
tracing::error!("Failed to emit show-registrations event: {}", e);
});
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to show window: {}", e);
}
}
_ => { _ => {
log::warn!("Unhandled menu event: {:?}", event.id); tracing::warn!("Unhandled menu event: {:?}", event.id);
} }
}) })
.on_tray_icon_event(|tray, event| { .on_tray_icon_event(|tray, event| {
@ -79,9 +97,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
} = event } = event
{ {
let app = tray.app_handle(); let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") { if let Err(e) = show_main_window(&app) {
let _ = window.show(); tracing::error!("Failed to show window from tray: {}", e);
let _ = window.set_focus();
} }
} }
}) })

View file

@ -7,6 +7,14 @@ use axum::{
}; };
use rand::rngs::OsRng; use rand::rngs::OsRng;
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::global::NONCE;
pub fn generate_auth_key_hash(auth_key: &str) -> String {
let hash = Sha256::digest(auth_key.as_bytes());
base16::encode_lower(&hash[..3])
}
pub fn open_link(link: &str) -> Option<()> { pub fn open_link(link: &str) -> Option<()> {
let null = Stdio::null(); let null = Stdio::null();
@ -79,7 +87,7 @@ where
let response_headers = response.headers_mut(); let response_headers = response.headers_mut();
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap()); response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
response_headers.insert("X-Hopp-Nonce", nonce_b16.parse().unwrap()); response_headers.insert(NONCE, nonce_b16.parse().unwrap());
response response
} }

View file

@ -182,7 +182,7 @@ pub fn init_webview() {
) )
.not() .not()
{ {
log::warn!("Declined to setup WebView."); tracing::warn!("Declined to setup WebView.");
std::process::exit(1); std::process::exit(1);
} }
@ -196,7 +196,7 @@ pub fn init_webview() {
)); ));
if let Err(e) = open_install_website() { if let Err(e) = open_install_website() {
log::warn!("Failed to launch WebView website:\n{}", e); tracing::warn!("Failed to launch WebView website:\n{}", e);
} }
std::process::exit(1); std::process::exit(1);

View file

@ -1,71 +1,179 @@
<template> <template>
<div class="font-sans min-h-screen flex flex-col"> <div class="h-screen p-5 flex flex-col gap-y-2">
<div class="p-5 flex flex-col flex-grow gap-y-2"> <h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1>
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
<template v-if="isOtpView(state())">
<div v-if="state().otp" class="flex-grow">
<p class="tracking-wide"> <p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please close the window if you did not initiate this request. the app to complete the registration process. Please hide the window if you did not initiate this request.
Do not close this window until the verification code is entered. Once done, this window will close by itself. Do not hide this window until the verification code is entered. The window will hide automatically once done.
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">
{{ otpCode }}
</p> </p>
<p
class="font-bold text-5xl tracking-wider text-center pt-10 text-white"
>{{ pipe(state().otp, O.getOrElse(() => "")) }}</p>
</div> </div>
<div v-else class="text-center pt-10 flex-grow">
<p class="tracking-wide">Waiting for registration requests...</p>
<p
class="text-sm text-gray-400 mt-2"
>You can hide this window and access it again from the tray icon.</p>
</div>
</template>
<template v-else>
<div class="flex-grow overflow-auto">
<HoppSmartTable :headings="TABLE_HEADINGS" :list="state().registrations">
<template #registered_at="{ item }">{{ formatDate(item.registered_at) }}</template>
</HoppSmartTable>
</div>
</template>
<div class="border-t border-divider p-5 flex justify-between"> <div class="border-t border-divider p-5 flex justify-between">
<HoppButtonSecondary <HoppButtonSecondary
v-if="shouldShowCopy(state())"
label="Copy Code" label="Copy Code"
outline outline
filled filled
:icon="copyIcon" :icon="copyIcon"
@click="copyCode" @click="copyOtp"
/>
<HoppButtonPrimary
label="Close"
outline
@click="closeWindow"
/> />
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, markRaw, onMounted } from "vue" import { ref, markRaw, onMounted } from "vue"
import { HoppButtonPrimary, HoppButtonSecondary } from "@hoppscotch/ui" import {
HoppButtonPrimary,
HoppButtonSecondary,
HoppSmartTable,
} from "@hoppscotch/ui"
import IconCopy from "~icons/lucide/copy" import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import { useClipboard, refAutoReset } from "@vueuse/core" import { useClipboard, refAutoReset } from "@vueuse/core"
import { getCurrentWindow } from "@tauri-apps/api/window" import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { listen } from '@tauri-apps/api/event' import { listen } from "@tauri-apps/api/event"
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { orderBy } from "lodash-es"
const { copy } = useClipboard() interface Registration {
const otpCode = ref("") auth_key_hash: string
const copyIcon = refAutoReset(markRaw(IconCopy), 3000) registered_at: string
function copyCode() {
copyIcon.value = markRaw(IconCheck)
copy(otpCode.value)
} }
function closeWindow() { interface AppState {
const currentWindow = getCurrentWindow() view: "otp" | "registrations"
currentWindow.close() otp: O.Option<string>
registrations: Registration[]
}
const TABLE_HEADINGS = [
{ key: "auth_key_hash", label: "ID" },
{ key: "registered_at", label: "Registered At" },
] as const
const { copy } = useClipboard()
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
const appState = ref<AppState>({
view: "otp",
otp: O.none,
registrations: [],
})
const state = () => appState.value
const isOtpView = (s: AppState): boolean => s.view === "otp"
const getTitle = (s: AppState): string =>
s.view === "otp" ? "Agent Registration Request" : "Agent Registrations"
const shouldShowCopy = (s: AppState): boolean => isOtpView(s) && O.isSome(s.otp)
const formatDate = (date: string): string => new Date(date).toLocaleString()
const getOtp = TE.tryCatch(
() => invoke<string>("get_otp", {}),
(error: unknown) => new Error(String(error))
)
const listRegistrations = TE.tryCatch(
async () => {
const result = await invoke<{ registrations: Registration[] }>(
"list_registrations",
{}
)
return orderBy(result.registrations, "registered_at", "desc")
},
(error: unknown) => new Error(String(error))
)
const hideWindow = () => {
getCurrentWindow().hide()
appState.value = { ...state(), otp: O.none }
}
const copyOtp = () => {
pipe(
state().otp,
O.map((otp: string) => {
copyIcon.value = markRaw(IconCheck)
copy(otp)
})
)
}
const updateRegistrations = async () => {
await pipe(
listRegistrations,
TE.map((registrations: Registration) => {
appState.value = { ...state(), registrations }
})
)()
}
const handleRegistrationReceived = (payload: string) => {
appState.value = {
...state(),
view: "otp",
otp: O.some(payload),
}
getCurrentWindow().setFocus()
}
const handleAuthenticated = async () => {
appState.value = { ...state(), otp: O.none }
await updateRegistrations()
hideWindow()
}
const handleShowRegistrations = async () => {
appState.value = { ...state(), view: "registrations" }
await updateRegistrations()
} }
onMounted(async () => { onMounted(async () => {
const currentWindow = getCurrentWindow() getCurrentWindow().setAlwaysOnTop(true)
currentWindow.setFocus(true); await pipe(
currentWindow.setAlwaysOnTop(true); getOtp,
TE.map((otp: string) => {
otpCode.value = await invoke("get_otp", {}) if (otp) appState.value = { ...state(), otp: O.some(otp) }
await listen('registration_received', (event) => {
otpCode.value = event.payload
}) })
)()
await listen('authenticated', () => { await Promise.all([
closeWindow() listen<string>(
}) "registration-received",
({ payload }: { payload: string }) => handleRegistrationReceived(payload)
),
listen(
"window-hidden",
() => (appState.value = { ...state(), otp: O.none })
),
listen("authenticated", handleAuthenticated),
listen("show-registrations", handleShowRegistrations),
])
}) })
</script> </script>

View file

@ -0,0 +1,80 @@
<template>
<div class="p-5 h-full flex flex-col flex-grow gap-y-2 justify-between">
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
<div v-if="otpCode">
<p class="tracking-wide">
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
the app to complete the registration process. Please hide the window if you did not initiate this request.
Do not hide this window until the verification code is entered. The window will hide automatically once done.
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">{{ otpCode }}</p>
</div>
<div v-else class="text-center pt-10">
<p class="tracking-wide">Waiting for registration requests...</p>
<p
class="text-sm text-gray-400 mt-2"
>You can hide this window and access it again from the tray icon.</p>
</div>
<div class="border-t border-divider p-5 flex justify-between">
<HoppButtonSecondary
v-if="otpCode"
label="Copy Code"
outline
filled
:icon="copyIcon"
@click="copyCode"
/>
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" />
</div>
</div>
</template>
<script setup>
import { ref, markRaw, onMounted } from "vue"
import { HoppButtonPrimary, HoppButtonSecondary } from "@hoppscotch/ui"
import IconCopy from "~icons/lucide/copy"
import IconCheck from "~icons/lucide/check"
import { useClipboard, refAutoReset } from "@vueuse/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
const { copy } = useClipboard()
const otpCode = ref("")
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
function copyCode() {
copyIcon.value = markRaw(IconCheck)
copy(otpCode.value)
}
function hideWindow() {
const currentWindow = getCurrentWindow()
currentWindow.hide()
otpCode.value = ""
}
onMounted(async () => {
const currentWindow = getCurrentWindow()
currentWindow.setAlwaysOnTop(true)
const initialOtp = await invoke("get_otp", {})
if (initialOtp) {
otpCode.value = initialOtp
}
await listen("registration-received", (event) => {
otpCode.value = event.payload
currentWindow.setFocus()
})
await listen("window-hidden", () => {
otpCode.value = ""
})
await listen("authenticated", () => {
otpCode.value = ""
hideWindow()
})
})
</script>

View file

@ -0,0 +1,57 @@
<template>
<div class="p-5 h-full flex flex-col flex-grow gap-y-2 justify-between">
<h1 class="font-bold text-lg text-white">Agent Registrations</h1>
<div class="overflow-auto">
<HoppSmartTable
:headings="[
{ key: 'auth_key_hash', label: 'ID' },
{ key: 'registered_at', label: 'Registered At' },
]"
:list="registrations"
>
<template #registered_at="{ item }">{{ formatDate(item.registered_at) }}</template>
</HoppSmartTable>
</div>
<div class="border-t border-divider p-5 flex justify-between">
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" />
</div>
</div>
</template>
<script setup>
import { ref, markRaw, onMounted } from "vue"
import { HoppButtonPrimary, HoppSmartTable } from "@hoppscotch/ui"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { listen } from "@tauri-apps/api/event"
import { orderBy } from "lodash-es"
const registrations = ref([])
function formatDate(date) {
return new Date(date).toLocaleString()
}
function hideWindow() {
const currentWindow = getCurrentWindow()
currentWindow.hide()
}
async function loadRegistrations() {
const result = await invoke("list_registrations", {})
registrations.value = orderBy(result.registrations, "registered_at", "desc")
}
onMounted(async () => {
const currentWindow = getCurrentWindow()
currentWindow.setAlwaysOnTop(true)
await loadRegistrations()
await listen("authenticated", () => {
loadRegistrations()
})
await listen("show-registrations", () => {
loadRegistrations()
})
})
</script>

View file

@ -1,4 +1,4 @@
import { ForbiddenException, HttpException, Module } from '@nestjs/common'; import { HttpException, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql'; import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -8,7 +8,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
import { UserEnvironmentsModule } from './user-environment/user-environments.module'; import { UserEnvironmentsModule } from './user-environment/user-environments.module';
import { UserRequestModule } from './user-request/user-request.module'; import { UserRequestModule } from './user-request/user-request.module';
import { UserHistoryModule } from './user-history/user-history.module'; import { UserHistoryModule } from './user-history/user-history.module';
import { subscriptionContextCookieParser } from './auth/helper'; import { subscriptionContextCookieParser, extractAccessTokenFromAuthRecords } from './auth/helper';
import { TeamModule } from './team/team.module'; import { TeamModule } from './team/team.module';
import { TeamEnvironmentsModule } from './team-environments/team-environments.module'; import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
import { TeamCollectionModule } from './team-collection/team-collection.module'; import { TeamCollectionModule } from './team-collection/team-collection.module';
@ -52,20 +52,29 @@ import { InfraTokenModule } from './infra-token/infra-token.module';
subscriptions: { subscriptions: {
'subscriptions-transport-ws': { 'subscriptions-transport-ws': {
path: '/graphql', path: '/graphql',
onConnect: (_, websocket) => { onConnect: (connectionParams, websocket) => {
const websocketHeaders = websocket?.upgradeReq?.headers;
try { try {
const cookies = subscriptionContextCookieParser( const accessToken = extractAccessTokenFromAuthRecords(connectionParams);
websocket.upgradeReq.headers.cookie, const authorization = `Bearer ${accessToken}`
);
return { return { headers: { ...websocketHeaders, authorization } };
headers: { ...websocket?.upgradeReq?.headers, cookies }, } catch (authError) {
}; const cookiesFromHeader = websocketHeaders?.cookie;
} catch (error) { const cookies = cookiesFromHeader
? subscriptionContextCookieParser(cookiesFromHeader)
: null;
if (!cookies) {
throw new HttpException(COOKIES_NOT_FOUND, 400, { throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND), cause: new Error(COOKIES_NOT_FOUND),
}); });
} }
},
return { headers: { ...websocketHeaders, cookies } };
}
}
}, },
}, },
context: ({ req, res, connection }) => ({ context: ({ req, res, connection }) => ({

View file

@ -193,4 +193,24 @@ export class AuthController {
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left); if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
return userInfo.right; return userInfo.right;
} }
@Get('desktop')
@UseGuards(JwtAuthGuard)
@UseInterceptors(UserLastLoginInterceptor)
async desktopAuthCallback(
@GqlUser() user: AuthUser,
@Query('redirect_uri') redirectUri: string,
) {
if (!redirectUri || !redirectUri.startsWith('http://localhost')) {
throwHTTPErr({
message: 'Invalid desktop callback URL',
statusCode: 400
});
}
const tokens = await this.authService.generateAuthTokens(user.uid);
if (E.isLeft(tokens)) throwHTTPErr(tokens.left);
return tokens.right;
}
} }

View file

@ -3,9 +3,10 @@ import { DateTime } from 'luxon';
import { AuthTokens } from 'src/types/AuthTokens'; import { AuthTokens } from 'src/types/AuthTokens';
import { Response } from 'express'; import { Response } from 'express';
import * as cookie from 'cookie'; import * as cookie from 'cookie';
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors'; import { AUTH_HEADER_NOT_FOUND, AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND, INVALID_AUTH_HEADER } from 'src/errors';
import { throwErr } from 'src/utils'; import { throwErr } from 'src/utils';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { IncomingHttpHeaders } from 'http';
enum AuthTokenType { enum AuthTokenType {
ACCESS_TOKEN = 'access_token', ACCESS_TOKEN = 'access_token',
@ -125,3 +126,94 @@ export function authProviderCheck(
return true; return true;
} }
/**
* Extract cookie as key-value pairs from headers of a request
* @param headers HTTP request headers containing auth tokens
* @returns Cookie's key-value pairs
*/
export const extractCookieAsKeyValuesFromHeaders = (headers: IncomingHttpHeaders) => {
const cookieHeader = headers['cookie'] || headers['Cookie'] || headers['COOKIE'];
if (!cookieHeader) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
const cookieStr = Array.isArray(cookieHeader) ? cookieHeader[0] : cookieHeader;
const kv = cookieStr.split(';')
.map(cookie => cookie.trim())
.reduce((acc, curr) => {
const [key, value] = curr.split('=');
acc[key] = value;
return acc;
}, {} as Record<string, string>);
return kv;
};
/**
* Extract auth tokens from cookie headers of a request
* @param headers HTTP request headers containing auth tokens
* @returns AuthTokens for JWT strategy to use
*/
export const extractAuthTokensFromCookieHeaders = (headers: IncomingHttpHeaders) => {
const cookieKV = extractCookieAsKeyValuesFromHeaders(headers);
if (!cookieKV[AuthTokenType.ACCESS_TOKEN] || !cookieKV[AuthTokenType.REFRESH_TOKEN]) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
return <AuthTokens>{
access_token: cookieKV[AuthTokenType.ACCESS_TOKEN],
refresh_token: cookieKV[AuthTokenType.REFRESH_TOKEN],
};
};
/**
* Extract access tokens from cookie headers of a request
* @param headers HTTP request headers containing access tokens
* @returns AccessTokens for JWT strategy to use
*/
export const extractAccessTokensFromCookieHeaders = (headers: IncomingHttpHeaders) => {
const cookieKV = extractCookieAsKeyValuesFromHeaders(headers);
if (!cookieKV[AuthTokenType.ACCESS_TOKEN]) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
return {
access_token: cookieKV[AuthTokenType.ACCESS_TOKEN],
};
};
/**
* Extract access token from authorization header
* @param headers HTTP request headers containing bearer token
* @returns AccessTokens for JWT strategy
*/
export const extractAccessTokenFromAuthRecords = (headers: IncomingHttpHeaders) => {
const authHeader = headers['authorization'] || headers['Authorization'];
if (!authHeader) {
throw new HttpException(AUTH_HEADER_NOT_FOUND, 400, {
cause: new Error(AUTH_HEADER_NOT_FOUND),
});
}
const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader;
const [bearer, access_token] = headerValue.split(' ');
if (bearer !== 'Bearer' || !access_token) {
throw new HttpException(INVALID_AUTH_HEADER, 400, {
cause: new Error(INVALID_AUTH_HEADER),
});
}
return access_token;
};

View file

@ -1,21 +1,66 @@
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { AccessTokenPayload } from 'src/types/AuthTokens'; import { AccessTokenPayload } from 'src/types/AuthTokens';
import { UserService } from 'src/user/user.service'; import { UserService } from 'src/user/user.service';
import { AuthService } from '../auth.service'; import { ConfigService } from '@nestjs/config';
import { Request } from 'express'; import { Request } from 'express';
import * as O from 'fp-ts/Option'; import * as O from 'fp-ts/Option';
import { import * as E from 'fp-ts/Either';
COOKIES_NOT_FOUND, import { pipe } from 'fp-ts/function';
INVALID_ACCESS_TOKEN, import { COOKIES_NOT_FOUND, INVALID_ACCESS_TOKEN, USER_NOT_FOUND } from 'src/errors';
USER_NOT_FOUND,
} from 'src/errors'; /**
import { ConfigService } from '@nestjs/config'; * Extracts an access token from a cookie in the request.
*
* @param request - Express Request object
* @returns Option<string> containing the token if found
*/
const extractFromCookie = (request: Request): O.Option<string> =>
pipe(
O.fromNullable(request.cookies),
O.chain(cookies => O.fromNullable(cookies['access_token']))
);
/**
* Extracts an access token from the Authorization header.
* Expects the header to be in the format: 'Bearer <token>'.
*
* @param request - Express Request object
* @returns Option<string> containing the token if found
*/
const extractFromAuthHeaders = (request: Request): O.Option<string> =>
pipe(
// First try headers.authorization, then fall back to root level authorization
// see `gql-auth.guard` for more info.
O.fromNullable(
request?.headers?.authorization ||
(request && 'authorization' in request ? request['authorization'] : undefined)
),
O.chain(auth =>
typeof auth === 'string' && auth.startsWith('Bearer ')
? O.some(auth.slice(7))
: O.none
)
);
/**
* Combines cookie and header token extraction strategies.
* Attempts to extract from cookie first, then falls back to Authorization header.
*
* @param request - Express Request object
* @returns Either<Error, string> containing the token or an error
*/
const extractToken = (request: Request): E.Either<Error, string> =>
pipe(
extractFromCookie(request),
O.alt(() => extractFromAuthHeaders(request)),
// Neither `Authorization` header nor `Cookie` were found with the request,
// `COOKIES_NOT_FOUND` for backwards compatibility.
E.fromOption(() => {
return new ForbiddenException(COOKIES_NOT_FOUND);
})
);
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -25,13 +70,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
) { ) {
super({ super({
jwtFromRequest: ExtractJwt.fromExtractors([ jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => { (request: Request) =>
const ATCookie = request.cookies['access_token']; pipe(
if (!ATCookie) { extractToken(request),
throw new ForbiddenException(COOKIES_NOT_FOUND); E.fold(
} error => { throw error; },
return ATCookie; token => { return token }
}, )
),
]), ]),
secretOrKey: configService.get('JWT_SECRET'), secretOrKey: configService.get('JWT_SECRET'),
}); });

View file

@ -27,6 +27,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
(request: Request) => { (request: Request) => {
const RTCookie = request.cookies['refresh_token']; const RTCookie = request.cookies['refresh_token'];
if (!RTCookie) { if (!RTCookie) {
console.error("`refresh_token` not found")
throw new ForbiddenException(COOKIES_NOT_FOUND); throw new ForbiddenException(COOKIES_NOT_FOUND);
} }
return RTCookie; return RTCookie;

View file

@ -593,6 +593,18 @@ export const TOKEN_EXPIRED = 'auth/token_expired' as const;
*/ */
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const; export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
/**
* Auth header was NOT found in the auth request
* (AuthService)
*/
export const AUTH_HEADER_NOT_FOUND = 'auth/auth_header_not_found' as const;
/**
* Auth header was found but the format was invalid
* (AuthService)
*/
export const INVALID_AUTH_HEADER = 'auth/invalid_auth_header' as const;
/** /**
* No cookies were found in the auth request * No cookies were found in the auth request
* (AuthService) * (AuthService)

View file

@ -9,8 +9,11 @@
"select_workspace": "Select a workspace", "select_workspace": "Select a workspace",
"clear": "Clear", "clear": "Clear",
"clear_all": "Clear all", "clear_all": "Clear all",
"clear_cache": "Clear Cache",
"clear_history": "Clear all History", "clear_history": "Clear all History",
"clear_unpinned": "Clear Unpinned",
"close": "Close", "close": "Close",
"confirm": "Confirm",
"connect": "Connect", "connect": "Connect",
"connecting": "Connecting", "connecting": "Connecting",
"copy": "Copy", "copy": "Copy",
@ -18,6 +21,7 @@
"delete": "Delete", "delete": "Delete",
"disconnect": "Disconnect", "disconnect": "Disconnect",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"done": "Done",
"dont_save": "Don't save", "dont_save": "Don't save",
"download_file": "Download file", "download_file": "Download file",
"download_test_report": "Download test report", "download_test_report": "Download test report",
@ -42,6 +46,7 @@
"properties": "Properties", "properties": "Properties",
"register": "Register", "register": "Register",
"remove": "Remove", "remove": "Remove",
"remove_instance": "Remove instance",
"rename": "Rename", "rename": "Rename",
"restore": "Restore", "restore": "Restore",
"retry": "Retry", "retry": "Retry",
@ -96,7 +101,6 @@
"enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration", "enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration",
"otp_label": "Verification Code", "otp_label": "Verification Code",
"processing": "Processing your request...", "processing": "Processing your request...",
"not_running": "The Hoppscotch Agent is not running. Please start the agent and click 'Retry'.",
"not_running_title": "Agent not detected", "not_running_title": "Agent not detected",
"registration_title": "Agent registration", "registration_title": "Agent registration",
"verify_ssl_certs": "Verify SSL Certificates", "verify_ssl_certs": "Verify SSL Certificates",
@ -255,8 +259,7 @@
"nonce_count": "Nonce Count", "nonce_count": "Nonce Count",
"client_nonce": "Client Nonce", "client_nonce": "Client Nonce",
"opaque": "Opaque", "opaque": "Opaque",
"disable_retry": "Disable Retrying Request", "disable_retry": "Disable Retrying Request"
"inspector_warning": "Agent interceptor is recommended when using Digest Authorization."
} }
}, },
"collection": { "collection": {
@ -416,6 +419,43 @@
"details": "Details" "details": "Details"
}, },
"error": { "error": {
"network": {
"heading": "Network Error",
"description": "Network connection failed. {message}: {cause}"
},
"timeout": {
"heading": "Timeout Error",
"description": "Request timed out during {phase}. {message}"
},
"certificate": {
"heading": "Certificate Error",
"description": "Invalid certificate. {message}: {cause}"
},
"auth": {
"heading": "Authentication Error",
"description": "Access denied. {message}: {cause}"
},
"proxy": {
"heading": "Proxy Error",
"description": "Proxy connection failed. {message}: {cause}"
},
"parse": {
"heading": "Parse Error",
"description": "Failed to parse response. {message}: {cause}"
},
"version": {
"heading": "Version Error",
"description": "Incompatible versions. {message}: {cause}"
},
"abort": {
"heading": "Request Aborted",
"description": "Operation cancelled. {message}: {cause}"
},
"unknown": {
"heading": "Unknown Error",
"description": "An unknown error occurred.",
"cause": "Unknown cause"
},
"authproviders_load_error": "Unable to load auth providers", "authproviders_load_error": "Unable to load auth providers",
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.", "browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.", "check_console_details": "Check console log for details.",
@ -536,7 +576,8 @@
"collection": "Collapse Collection Panel", "collection": "Collapse Collection Panel",
"more": "Hide more", "more": "Hide more",
"preview": "Hide Preview", "preview": "Hide Preview",
"sidebar": "Collapse sidebar" "sidebar": "Collapse sidebar",
"password": "Hide Password"
}, },
"import": { "import": {
"collections": "Import collections", "collections": "Import collections",
@ -591,6 +632,17 @@
"import_summary_test_scripts_title": "Test scripts", "import_summary_test_scripts_title": "Test scripts",
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now." "import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now."
}, },
"instances": {
"switch": "Switch Hoppscotch Instance",
"enter_server_url": "Connect to a self-hosted instance",
"already_connected": "You are already connected to this instance",
"recent_connections": "Recent Connections",
"add_instance": "Add an instance",
"add_new": "Add a new instance",
"confirm_remove": "Confirm Removal",
"remove_warning": "Are you sure you want to remove this instance?",
"clear_cached_bundles": "Clear cached bundles"
},
"inspections": { "inspections": {
"description": "Inspect possible errors", "description": "Inspect possible errors",
"environment": { "environment": {
@ -614,10 +666,36 @@
"extension_not_installed": "Extension not installed.", "extension_not_installed": "Extension not installed.",
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.", "extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
"extention_enable_action": "Enable Browser Extension", "extention_enable_action": "Enable Browser Extension",
"extention_not_enabled": "Extension not enabled." "extention_not_enabled": "Extension not enabled.",
"localaccess_unsupported": "Current interceptor does not support local access, please consider using Agent, Extension interceptors or the Desktop App"
}, },
"requestBody": { "auth": {
"active_interceptor_doesnt_support_binary_body": "Sending binary data via the current interceptor is not supported yet." "digest": "Agent interceptor or the Hoppscotch Desktop app are recommended when using Digest Authorization."
},
"body": {
"binary": "Sending binary data via the current interceptor is not supported yet."
}
},
"interceptor": {
"native": {
"name": "Native",
"settings_title": "Native"
},
"agent": {
"name": "Agent",
"settings_title": "Agent"
},
"proxy": {
"name": "Proxy",
"settings_title": "Proxy"
},
"browser": {
"name": "Browser",
"settings_title": "Browser"
},
"extension": {
"name": "Extension",
"settings_title": "Extension"
} }
}, },
"layout": { "layout": {
@ -797,6 +875,13 @@
"account_email_description": "Your primary email address.", "account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.", "account_name_description": "This is your display name.",
"additional": "Additional Settings", "additional": "Additional Settings",
"agent_not_running": "Hoppscotch Agent not detected - click `Retry` to check again.",
"agent_not_running_short": "Check Agent's status.",
"agent_running": "Hoppscotch Agent is live.",
"agent_running_short": "Hoppscotch Agent is live.",
"agent_reset_registration": "Reset Registration",
"agent_registered": "Agent Registered",
"agent_registration_successful": "Agent Registered Successfully",
"auto_encode_mode": "Auto", "auto_encode_mode": "Auto",
"auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present", "auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present",
"background": "Background", "background": "Background",
@ -807,6 +892,7 @@
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.", "delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
"disable_encode_mode_tooltip": "Never encode the parameters in the request", "disable_encode_mode_tooltip": "Never encode the parameters in the request",
"enable_encode_mode_tooltip": "Always encode the parameters in the request", "enable_encode_mode_tooltip": "Always encode the parameters in the request",
"enter_otp": "Enter Agent's code",
"expand_navigation": "Expand navigation", "expand_navigation": "Expand navigation",
"experiments": "Experiments", "experiments": "Experiments",
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ", "experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",
@ -819,6 +905,8 @@
"general_description": " General settings used in the application", "general_description": " General settings used in the application",
"interceptor": "Interceptor", "interceptor": "Interceptor",
"interceptor_description": "Middleware between application and APIs.", "interceptor_description": "Middleware between application and APIs.",
"kernel_interceptor": "Interceptor",
"kernel_interceptor_description": "Middleware between application and APIs.",
"language": "Language", "language": "Language",
"light_mode": "Light", "light_mode": "Light",
"official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.", "official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.",
@ -858,7 +946,33 @@
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting", "use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
"user": "User", "user": "User",
"verified_email": "Verified email", "verified_email": "Verified email",
"verify_email": "Verify email" "verify_email": "Verify email",
"validate_certificates": "Validate SSL/TLS Certificates",
"verify_host": "Verify Host",
"verify_peer": "Verify Peer",
"client_certificates": "Client Certificates",
"certificate_settings": "Certificate Settings",
"certificate": "Certificate",
"key": "Private Key",
"pfx_or_p12": "PFX/PKCS#12",
"password": "Password",
"select_file": "Select File",
"domain": "Domain",
"add_certificate": "Add Certificate",
"add_cert_file": "Add Certificate File",
"add_key_file": "Add Key File",
"add_pfx_file": "Add PFX File",
"global_defaults": "Global Defaults",
"add_domain_override": "Add Domain Override",
"domain_override": "Domain Override",
"manage_domains_overrides": "Manage Domains Overrides",
"add_domain": "Add Domain",
"remove_domain": "Remove Domain",
"ca_certificate": "CA Certificate",
"ca_certificates": "CA Certificates",
"ca_certificates_support": "Hoppscotch supports .crt, .cer or .pem files containing one or more certificates.",
"proxy_capabilities": "Hoppscotch Agent and Desktop App supports HTTP/HTTPS/SOCKS proxies with NTLM and Basic Auth support.",
"proxy_auth": "You can also include username and password in the URL."
}, },
"shared_requests": { "shared_requests": {
"button": "Button", "button": "Button",
@ -967,7 +1081,8 @@
"code": "Show code", "code": "Show code",
"collection": "Expand Collection Panel", "collection": "Expand Collection Panel",
"more": "Show more", "more": "Show more",
"sidebar": "Expand sidebar" "sidebar": "Expand sidebar",
"password": "Show Password"
}, },
"socketio": { "socketio": {
"communication": "Communication", "communication": "Communication",

View file

@ -34,16 +34,19 @@
"@codemirror/search": "6.5.6", "@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@codemirror/view": "6.25.1", "@codemirror/view": "6.25.1",
"@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload",
"@hoppscotch/codemirror-lang-graphql": "workspace:^", "@hoppscotch/codemirror-lang-graphql": "workspace:^",
"@hoppscotch/data": "workspace:^", "@hoppscotch/data": "workspace:^",
"@hoppscotch/httpsnippet": "3.0.7", "@hoppscotch/httpsnippet": "3.0.7",
"@hoppscotch/js-sandbox": "workspace:^", "@hoppscotch/js-sandbox": "workspace:^",
"@hoppscotch/kernel": "workspace:^",
"@hoppscotch/ui": "0.2.2", "@hoppscotch/ui": "0.2.2",
"@hoppscotch/vue-toasted": "0.1.0", "@hoppscotch/vue-toasted": "0.1.0",
"@lezer/highlight": "1.2.0", "@lezer/highlight": "1.2.0",
"@noble/curves": "1.6.0", "@noble/curves": "1.6.0",
"@scure/base": "1.1.9", "@scure/base": "1.1.9",
"@shopify/lang-jsonc": "1.0.0", "@shopify/lang-jsonc": "1.0.0",
"@tauri-apps/plugin-store": "2.2.0",
"@types/markdown-it": "14.1.2", "@types/markdown-it": "14.1.2",
"@unhead/vue": "1.11.10", "@unhead/vue": "1.11.10",
"@urql/core": "5.0.6", "@urql/core": "5.0.6",
@ -90,6 +93,7 @@
"splitpanes": "3.1.5", "splitpanes": "3.1.5",
"stream-browserify": "3.0.0", "stream-browserify": "3.0.0",
"subscriptions-transport-ws": "0.11.0", "subscriptions-transport-ws": "0.11.0",
"superjson": "2.2.2",
"tern": "0.24.3", "tern": "0.24.3",
"timers": "0.1.1", "timers": "0.1.1",
"tippy.js": "6.3.7", "tippy.js": "6.3.7",

View file

@ -24,6 +24,7 @@ declare module 'vue' {
AppHeader: typeof import('./components/app/Header.vue')['default'] AppHeader: typeof import('./components/app/Header.vue')['default']
AppInspection: typeof import('./components/app/Inspection.vue')['default'] AppInspection: typeof import('./components/app/Inspection.vue')['default']
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default'] AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
AppKernelInterceptor: typeof import('./components/app/KernelInterceptor.vue')['default']
AppLogo: typeof import('./components/app/Logo.vue')['default'] AppLogo: typeof import('./components/app/Logo.vue')['default']
AppMarkdown: typeof import('./components/app/Markdown.vue')['default'] AppMarkdown: typeof import('./components/app/Markdown.vue')['default']
AppOptions: typeof import('./components/app/Options.vue')['default'] AppOptions: typeof import('./components/app/Options.vue')['default']
@ -230,6 +231,7 @@ declare module 'vue' {
ImportExportImportExportStepsImportSummary: typeof import('./components/importExport/ImportExportSteps/ImportSummary.vue')['default'] ImportExportImportExportStepsImportSummary: typeof import('./components/importExport/ImportExportSteps/ImportSummary.vue')['default']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default'] ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.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'] InterceptorsAgentModalNativeCACertificates: typeof import('./components/interceptors/agent/ModalNativeCACertificates.vue')['default']
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default'] InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default'] InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
@ -255,7 +257,10 @@ declare module 'vue' {
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default'] RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default'] RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
SettingsAgent: typeof import('./components/settings/Agent.vue')['default'] SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default']
SettingsExtension: typeof import('./components/settings/Extension.vue')['default'] SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
SettingsNative: typeof import('./components/settings/Native.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default'] SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default'] Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default'] ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']

View file

@ -2,6 +2,10 @@
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" /> <AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
<AppShare :show="showShare" @hide-modal="showShare = false" /> <AppShare :show="showShare" @hide-modal="showShare = false" />
<FirebaseLogin v-if="showLogin" @hide-modal="showLogin = false" /> <FirebaseLogin v-if="showLogin" @hide-modal="showLogin = false" />
<InstanceSwitcher
v-if="showInstanceSwitcher"
@hide-modal="showInstanceSwitcher = false"
/>
<HttpResponseInterface <HttpResponseInterface
v-if="isDrawerOpen" v-if="isDrawerOpen"
:show="isDrawerOpen" :show="isDrawerOpen"
@ -16,6 +20,7 @@ import { defineActionHandler } from "~/helpers/actions"
const showShortcuts = ref(false) const showShortcuts = ref(false)
const showShare = ref(false) const showShare = ref(false)
const showLogin = ref(false) const showLogin = ref(false)
const showInstanceSwitcher = ref(false)
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
defineActionHandler("flyouts.keybinds.toggle", () => { defineActionHandler("flyouts.keybinds.toggle", () => {
@ -30,6 +35,10 @@ defineActionHandler("modals.login.toggle", () => {
showLogin.value = !showLogin.value showLogin.value = !showLogin.value
}) })
defineActionHandler("modals.instance-switcher.toggle", () => {
showInstanceSwitcher.value = !showInstanceSwitcher.value
})
defineActionHandler("response.schema.toggle", () => { defineActionHandler("response.schema.toggle", () => {
isDrawerOpen.value = !isDrawerOpen.value isDrawerOpen.value = !isDrawerOpen.value
}) })

View file

@ -17,7 +17,7 @@
:icon="IconShieldCheck" :icon="IconShieldCheck"
/> />
<template #content> <template #content>
<AppInterceptor /> <AppKernelInterceptor />
</template> </template>
</tippy> </tippy>
<HoppButtonSecondary <HoppButtonSecondary

View file

@ -2,10 +2,11 @@
<div> <div>
<header <header
ref="headerRef" ref="headerRef"
data-tauri-drag-region
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2" class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
> >
<div <div
data-tauri-drag-region
class="col-span-2 flex items-center justify-between space-x-2" class="col-span-2 flex items-center justify-between space-x-2"
:style="{ :style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value, paddingTop: platform.ui?.appHeader?.paddingTop?.value,
@ -13,17 +14,50 @@
}" }"
> >
<div class="flex"> <div class="flex">
<tippy
v-if="kernelMode === 'desktop'"
interactive
trigger="click"
theme="popover"
:on-shown="() => instanceSwitcherRef.focus()"
>
<div class="flex items-center cursor-pointer">
<span
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
>
{{ instanceDisplayName }}
</span>
<IconChevronDown class="h-4 w-4 text-secondaryDark" />
</div>
<template #content="{ hide }">
<div
ref="instanceSwitcherRef"
class="flex flex-col focus:outline-none min-w-64"
tabindex="0"
@keyup.escape="hide()"
>
<InstanceSwitcher @close-dropdown="hide()" />
</div>
</template>
</tippy>
<HoppButtonSecondary <HoppButtonSecondary
v-else
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark" class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')" :label="t('app.name')"
to="/" to="/"
/> />
</div> </div>
</div> </div>
<div class="col-span-1 flex items-center justify-between space-x-2"> <div
data-tauri-drag-region
class="col-span-1 flex items-center justify-between space-x-2"
>
<AppSpotlightSearch /> <AppSpotlightSearch />
</div> </div>
<div class="col-span-2 flex items-center justify-between space-x-2"> <div
data-tauri-drag-region
class="col-span-2 flex items-center justify-between space-x-2"
>
<div class="flex"> <div class="flex">
<HoppButtonSecondary <HoppButtonSecondary
v-if="showInstallButton" v-if="showInstallButton"
@ -177,9 +211,8 @@
</span> </span>
<span <span
class="inline-flex truncate text-secondaryLight text-tiny" class="inline-flex truncate text-secondaryLight text-tiny"
>{{ currentUser.email }}</span
> >
{{ currentUser.email }}
</span>
</div> </div>
<hr /> <hr />
<HoppSmartItem <HoppSmartItem
@ -241,6 +274,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getKernelMode } from "@hoppscotch/kernel"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream" import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions" import { defineActionHandler, invokeAction } from "@helpers/actions"
@ -260,6 +295,7 @@ import {
BannerService, BannerService,
} from "~/services/banner.service" } from "~/services/banner.service"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { InstanceSwitcherService } from "~/services/instance-switcher.service"
import IconDownload from "~icons/lucide/download" import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy" import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings" import IconSettings from "~icons/lucide/settings"
@ -267,9 +303,33 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user" import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus" import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users" import IconUsers from "~icons/lucide/users"
import IconChevronDown from "~icons/lucide/chevron-down"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const kernelMode = getKernelMode()
const instanceSwitcherService =
kernelMode === "desktop" ? useService(InstanceSwitcherService) : null
const instanceSwitcherRef =
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
const currentState =
kernelMode === "desktop" && instanceSwitcherService
? useReadonlyStream(
instanceSwitcherService.getStateStream(),
instanceSwitcherService.getCurrentState().value
)
: ref({
status: "disconnected",
instance: { displayName: "Hoppscotch" },
})
const instanceDisplayName = computed(() => {
if (currentState.value.status !== "connected") {
return "Hoppscotch"
}
return currentState.value.instance.displayName
})
/** /**
* Feature flag to enable the workspace selector login conversion * Feature flag to enable the workspace selector login conversion

View file

@ -0,0 +1,97 @@
<template>
<div class="flex flex-col space-y-2">
<div v-if="isTooltipComponent" class="flex flex-col px-4 pt-2">
<h2 class="inline-flex pb-1 font-semibold text-secondaryDark">
{{ t("settings.kernel_interceptor") }}
</h2>
<p class="inline-flex text-tiny">
{{ t("settings.kernel_interceptor_description") }}
</p>
</div>
<div>
<div
v-for="kernelInterceptor in kernelInterceptors"
:key="kernelInterceptor.id"
class="flex flex-col"
>
<HoppSmartRadio
:value="kernelInterceptor.id"
:label="kernelInterceptor.name(t)"
:selected="kernelInterceptorSelection === kernelInterceptor.id"
:disabled="kernelInterceptor.selectable.type === 'unselectable'"
:class="{
'!px-0 hover:bg-transparent': !isTooltipComponent,
}"
@change="setKernelInterceptor(kernelInterceptor.id)"
/>
<div
v-if="kernelInterceptor.selectable.type === 'unselectable'"
class="px-4 py-1"
>
<template v-if="kernelInterceptor.selectable.reason.type === 'text'">
<p class="text-tiny text-secondaryLight">
{{ kernelInterceptor.selectable.reason.text(t) }}
</p>
<button
v-if="kernelInterceptor.selectable.reason.action"
class="text-tiny text-accent hover:text-accentDark"
@click="kernelInterceptor.selectable.reason.action.onActionClick"
>
{{ kernelInterceptor.selectable.reason.action.text(t) }}
</button>
</template>
<component
:is="kernelInterceptor.selectable.reason.component"
v-else-if="kernelInterceptor.selectable.reason.type === 'custom'"
v-bind="kernelInterceptor.selectable.reason.props ?? {}"
/>
</div>
<component
:is="kernelInterceptor.subtitle"
v-if="kernelInterceptor.subtitle"
class="ml-8"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from "@composables/i18n"
import { useService } from "dioc/vue"
import { computed } from "vue"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
const t = useI18n()
withDefaults(
defineProps<{
isTooltipComponent?: boolean
}>(),
{
isTooltipComponent: true,
}
)
const kernelInterceptorService = useService(KernelInterceptorService)
const kernelInterceptorSelection = computed(
() => kernelInterceptorService.current.value?.id ?? null
)
const kernelInterceptors = computed(
() => kernelInterceptorService.available.value
)
const setKernelInterceptor = (id: string) => {
if (!kernelInterceptors.value.some((ki) => ki.id === id)) {
console.warn("Attempted to set an unknown interceptor:", id)
return
}
kernelInterceptorService.setActive(id)
}
</script>

View file

@ -54,7 +54,7 @@ import "splitpanes/dist/splitpanes.css"
import { useSetting } from "@composables/settings" import { useSetting } from "@composables/settings"
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core" import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { computed, ref, useSlots } from "vue" import { computed, onMounted, ref, useSlots } from "vue"
import { PersistenceService } from "~/services/persistence" import { PersistenceService } from "~/services/persistence"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT") const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@ -104,23 +104,26 @@ if (!COLUMN_LAYOUT.value) {
PANE_MAIN_BOTTOM_SIZE.value = 50 PANE_MAIN_BOTTOM_SIZE.value = 50
} }
function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") { async function setPaneEvent(
event: PaneEvent[],
type: "vertical" | "horizontal"
) {
if (!props.layoutId) return if (!props.layoutId) return
const storageKey = `${props.layoutId}-pane-config-${type}` const storageKey = `${props.layoutId}-pane-config-${type}`
persistenceService.setLocalConfig(storageKey, JSON.stringify(event)) await persistenceService.setLocalConfig(storageKey, JSON.stringify(event))
} }
function populatePaneEvent() { async function populatePaneEvent() {
if (!props.layoutId) return if (!props.layoutId) return
const verticalPaneData = getPaneData("vertical") const verticalPaneData = await getPaneData("vertical")
if (verticalPaneData) { if (verticalPaneData) {
const [mainPane, sidebarPane] = verticalPaneData const [mainPane, sidebarPane] = verticalPaneData
PANE_MAIN_SIZE.value = mainPane?.size PANE_MAIN_SIZE.value = mainPane?.size
PANE_SIDEBAR_SIZE.value = sidebarPane?.size PANE_SIDEBAR_SIZE.value = sidebarPane?.size
} }
const horizontalPaneData = getPaneData("horizontal") const horizontalPaneData = await getPaneData("horizontal")
if (horizontalPaneData) { if (horizontalPaneData) {
const [mainTopPane, mainBottomPane] = horizontalPaneData const [mainTopPane, mainBottomPane] = horizontalPaneData
PANE_MAIN_TOP_SIZE.value = mainTopPane?.size PANE_MAIN_TOP_SIZE.value = mainTopPane?.size
@ -128,12 +131,16 @@ function populatePaneEvent() {
} }
} }
function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null { async function getPaneData(
type: "vertical" | "horizontal"
): Promise<PaneEvent[] | null> {
const storageKey = `${props.layoutId}-pane-config-${type}` const storageKey = `${props.layoutId}-pane-config-${type}`
const paneEvent = persistenceService.getLocalConfig(storageKey) const paneEvent = await persistenceService.getLocalConfig(storageKey)
if (!paneEvent) return null if (!paneEvent) return null
return JSON.parse(paneEvent) return JSON.parse(paneEvent)
} }
populatePaneEvent() onMounted(async () => {
await populatePaneEvent()
})
</script> </script>

View file

@ -48,7 +48,7 @@ defineEmits<{
}>() }>()
const openWhatsNew = () => { const openWhatsNew = () => {
if (props.notesUrl) platform.io.openExternalLink(props.notesUrl) if (props.notesUrl) platform.kernelIO.openExternalLink(props.notesUrl)
} }
</script> </script>

View file

@ -104,7 +104,9 @@ import {
} from "~/services/spotlight/searchers/environment.searcher" } from "~/services/spotlight/searchers/environment.searcher"
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher" import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher" import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.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 { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher" import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher" import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
@ -144,7 +146,9 @@ useService(EnvironmentsSpotlightSearcherService)
useService(SwitchEnvSpotlightSearcherService) useService(SwitchEnvSpotlightSearcherService)
useService(WorkspaceSpotlightSearcherService) useService(WorkspaceSpotlightSearcherService)
useService(SwitchWorkspaceSpotlightSearcherService) useService(SwitchWorkspaceSpotlightSearcherService)
useService(InterceptorSpotlightSearcherService) // NOTE: Old interceptors
// useService(InterceptorSpotlightSearcherService)
useService(KernelInterceptorSpotlightSearcherService)
useService(TeamsSpotlightSearcherService) useService(TeamsSpotlightSearcherService)
platform.spotlight?.additionalSearchers?.forEach((searcher) => platform.spotlight?.additionalSearchers?.forEach((searcher) =>

View file

@ -625,7 +625,7 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
platform: "rest", platform: "rest",
}) })
platform.io.openExternalLink(res.right) platform.kernelIO.openExternalLink(res.right)
} else { } else {
toast.error(collectionJSON.left) toast.error(collectionJSON.left)
} }

View file

@ -199,7 +199,7 @@ const activeTabIsDetails = computed(() => activeTab.value === "details")
watch( watch(
editableCollection, editableCollection,
(updatedEditableCollection) => { async (updatedEditableCollection) => {
if (props.show && props.editingProperties) { if (props.show && props.editingProperties) {
const unsavedCollectionProperties: EditingProperties = { const unsavedCollectionProperties: EditingProperties = {
collection: updatedEditableCollection, collection: updatedEditableCollection,
@ -207,7 +207,7 @@ watch(
path: props.editingProperties.path, path: props.editingProperties.path,
inheritedProperties: props.editingProperties.inheritedProperties, inheritedProperties: props.editingProperties.inheritedProperties,
} }
persistenceService.setLocalConfig( await persistenceService.setLocalConfig(
"unsaved_collection_properties", "unsaved_collection_properties",
JSON.stringify(unsavedCollectionProperties) JSON.stringify(unsavedCollectionProperties)
) )
@ -222,7 +222,7 @@ const activeTab = useVModel(props, "modelValue", emit)
watch( watch(
() => props.show, () => props.show,
(show) => { async (show) => {
// `Details` tab doesn't exist for personal workspace, hence switching to the `Headers` tab // `Details` tab doesn't exist for personal workspace, hence switching to the `Headers` tab
// The modal can appear empty while switching from a team workspace with `Details` as the active tab // The modal can appear empty while switching from a team workspace with `Details` as the active tab
if (activeTab.value === "details" && !props.showDetails) { if (activeTab.value === "details" && !props.showDetails) {
@ -245,12 +245,14 @@ watch(
}, },
} }
persistenceService.removeLocalConfig("unsaved_collection_properties") await persistenceService.removeLocalConfig(
"unsaved_collection_properties"
)
} }
} }
) )
const saveEditedCollection = () => { const saveEditedCollection = async () => {
if (!props.editingProperties) return if (!props.editingProperties) return
const finalCollection = clone(editableCollection.value) const finalCollection = clone(editableCollection.value)
const collection = { const collection = {
@ -262,11 +264,11 @@ const saveEditedCollection = () => {
isRootCollection: props.editingProperties.isRootCollection, isRootCollection: props.editingProperties.isRootCollection,
} }
emit("set-collection-properties", collection as EditingProperties) emit("set-collection-properties", collection as EditingProperties)
persistenceService.removeLocalConfig("unsaved_collection_properties") await persistenceService.removeLocalConfig("unsaved_collection_properties")
} }
const hideModal = () => { const hideModal = async () => {
persistenceService.removeLocalConfig("unsaved_collection_properties") await persistenceService.removeLocalConfig("unsaved_collection_properties")
emit("hide-modal") emit("hide-modal")
} }

View file

@ -208,7 +208,7 @@ const GqlCollectionsGistExporter: ImporterOrExporter = {
exporter: "gist", exporter: "gist",
}) })
platform.io.openExternalLink(res.right) platform.kernelIO.openExternalLink(res.right)
} }
isGqlCollectionGistExportInProgress.value = false isGqlCollectionGistExportInProgress.value = false

View file

@ -243,9 +243,9 @@ const persistenceService = useService(PersistenceService)
const collectionPropertiesModalActiveTab = ref<GQLOptionTabs>("headers") const collectionPropertiesModalActiveTab = ref<GQLOptionTabs>("headers")
onMounted(() => { onMounted(async () => {
const localOAuthTempConfig = const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config") await persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) { if (!localOAuthTempConfig) {
return return
@ -260,9 +260,8 @@ onMounted(() => {
if (context?.type === "collection-properties") { if (context?.type === "collection-properties") {
// load the unsaved editing properties // load the unsaved editing properties
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig( const unsavedCollectionPropertiesString =
"unsaved_collection_properties" await persistenceService.getLocalConfig("unsaved_collection_properties")
)
if (unsavedCollectionPropertiesString) { if (unsavedCollectionPropertiesString) {
const unsavedCollectionProperties: EditingProperties = JSON.parse( const unsavedCollectionProperties: EditingProperties = JSON.parse(
@ -284,7 +283,7 @@ onMounted(() => {
editingProperties.value = unsavedCollectionProperties editingProperties.value = unsavedCollectionProperties
} }
persistenceService.removeLocalConfig("oauth_temp_config") await persistenceService.removeLocalConfig("oauth_temp_config")
collectionPropertiesModalActiveTab.value = "authorization" collectionPropertiesModalActiveTab.value = "authorization"
showModalEditProperties.value = true showModalEditProperties.value = true
} }

View file

@ -440,9 +440,9 @@ const persistenceService = useService(PersistenceService)
const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers") const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers")
onMounted(() => { onMounted(async () => {
const localOAuthTempConfig = const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config") await persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) { if (!localOAuthTempConfig) {
return return
@ -457,9 +457,8 @@ onMounted(() => {
if (context?.type === "collection-properties") { if (context?.type === "collection-properties") {
// load the unsaved editing properties // load the unsaved editing properties
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig( const unsavedCollectionPropertiesString =
"unsaved_collection_properties" await persistenceService.getLocalConfig("unsaved_collection_properties")
)
if (unsavedCollectionPropertiesString) { if (unsavedCollectionPropertiesString) {
const unsavedCollectionProperties: EditingProperties = JSON.parse( const unsavedCollectionProperties: EditingProperties = JSON.parse(
@ -481,7 +480,7 @@ onMounted(() => {
editingProperties.value = unsavedCollectionProperties editingProperties.value = unsavedCollectionProperties
} }
persistenceService.removeLocalConfig("oauth_temp_config") await persistenceService.removeLocalConfig("oauth_temp_config")
collectionPropertiesModalActiveTab.value = "authorization" collectionPropertiesModalActiveTab.value = "authorization"
showModalEditProperties.value = true showModalEditProperties.value = true
} }
@ -2642,7 +2641,7 @@ const initializeDownloadCollection = async (
collectionJSON: string, collectionJSON: string,
name: string | null name: string | null
) => { ) => {
const result = await platform.io.saveFileWithDialog({ const result = await platform.kernelIO.saveFileWithDialog({
data: collectionJSON, data: collectionJSON,
contentType: "application/json", contentType: "application/json",
suggestedFilename: `${name ?? "collection"}.json`, suggestedFilename: `${name ?? "collection"}.json`,

View file

@ -149,10 +149,10 @@ import IconTrash2 from "~icons/lucide/trash-2"
import IconPlus from "~icons/lucide/plus" import IconPlus from "~icons/lucide/plus"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import { ref, watch, computed } from "vue" import { ref, watch, computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { EditCookieConfig } from "./EditCookie.vue" import { EditCookieConfig } from "./EditCookie.vue"
import { useColorMode } from "@composables/theming" import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast" import { useToast } from "@composables/toast"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
@ -168,17 +168,16 @@ const toast = useToast()
const newDomainText = ref("") const newDomainText = ref("")
const interceptorService = useService(InterceptorService) const interceptorService = useService(KernelInterceptorService)
const cookieJarService = useService(CookieJarService) const cookieJarService = useService(CookieJarService)
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value)) const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
const currentInterceptorSupportsCookies = computed(() => { const currentInterceptorSupportsCookies = computed(() => {
const currentInterceptor = interceptorService.currentInterceptor.value const capabilities = interceptorService.current.value?.capabilities
const supportsCookies = capabilities["advanced"].has("cookies")
if (!currentInterceptor) return true return supportsCookies ?? false
return currentInterceptor.supportsCookies ?? false
}) })
function addNewDomain() { function addNewDomain() {

View file

@ -328,7 +328,7 @@ const HoppEnvironmentsGistExporter: ImporterOrExporter = {
platform: "rest", platform: "rest",
}) })
platform.io.openExternalLink(res.right) platform.kernelIO.openExternalLink(res.right)
} }
isEnvironmentGistExportInProgress.value = false isEnvironmentGistExportInProgress.value = false

View file

@ -316,9 +316,9 @@ const signInWithEmail = async () => {
await platform.auth await platform.auth
.signInWithEmail(form.email) .signInWithEmail(form.email)
.then(() => { .then(async () => {
mode.value = "email-sent" mode.value = "email-sent"
persistenceService.setLocalConfig("emailForSignIn", form.email) await persistenceService.setLocalConfig("emailForSignIn", form.email)
}) })
.catch((e) => { .catch((e) => {
console.error(e) console.error(e)

View file

@ -68,7 +68,7 @@ import { computed, ref, watch } from "vue"
import { connection } from "~/helpers/graphql/connection" import { connection } from "~/helpers/graphql/connection"
import { connect } from "~/helpers/graphql/connection" import { connect } from "~/helpers/graphql/connection"
import { disconnect } from "~/helpers/graphql/connection" import { disconnect } from "~/helpers/graphql/connection"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { defineActionHandler } from "~/helpers/actions" import { defineActionHandler } from "~/helpers/actions"
import { GQLTabService } from "~/services/tab/graphql" import { GQLTabService } from "~/services/tab/graphql"
@ -77,7 +77,7 @@ import { HoppGQLAuth, HoppGQLRequest } from "@hoppscotch/data"
const t = useI18n() const t = useI18n()
const tabs = useService(GQLTabService) const tabs = useService(GQLTabService)
const interceptorService = useService(InterceptorService) const interceptorService = useService(KernelInterceptorService)
const connectionSwitchModal = ref(false) const connectionSwitchModal = ref(false)
@ -120,7 +120,7 @@ const gqlConnect = () => {
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN", type: "HOPP_REQUEST_RUN",
platform: "graphql-schema", platform: "graphql-schema",
strategy: interceptorService.currentInterceptorID.value!, strategy: interceptorService.current.value!.id,
}) })
} }

View file

@ -74,7 +74,7 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
import { completePageProgress, startPageProgress } from "~/modules/loadingbar" import { completePageProgress, startPageProgress } from "~/modules/loadingbar"
import { editGraphqlRequest } from "~/newstore/collections" import { editGraphqlRequest } from "~/newstore/collections"
import { platform } from "~/platform" import { platform } from "~/platform"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { GQLTabService } from "~/services/tab/graphql" import { GQLTabService } from "~/services/tab/graphql"
const VALID_GQL_OPERATIONS = [ const VALID_GQL_OPERATIONS = [
@ -86,7 +86,7 @@ const VALID_GQL_OPERATIONS = [
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number] export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
const interceptorService = useService(InterceptorService) const interceptorService = useService(KernelInterceptorService)
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
@ -174,7 +174,7 @@ const runQuery = async (
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN", type: "HOPP_REQUEST_RUN",
platform: "graphql-query", platform: "graphql-query",
strategy: interceptorService.currentInterceptorID.value!, strategy: interceptorService.current.value!.id,
}) })
} }

View file

@ -147,7 +147,7 @@ const downloadSchema = async () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
const result = await platform.io.saveFileWithDialog({ const result = await platform.kernelIO.saveFileWithDialog({
data: dataToWrite, data: dataToWrite,
contentType: "application/graphql", contentType: "application/graphql",
suggestedFilename: filename, suggestedFilename: filename,

View file

@ -261,15 +261,15 @@ import { platform } from "~/platform"
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { InspectionService } from "~/services/inspection" import { InspectionService } from "~/services/inspection"
import { InterceptorService } from "~/services/interceptor.service"
import { HoppTab } from "~/services/tab" import { HoppTab } from "~/services/tab"
import { HoppRequestDocument } from "~/helpers/rest/document" import { HoppRequestDocument } from "~/helpers/rest/document"
import { RESTTabService } from "~/services/tab/rest" import { RESTTabService } from "~/services/tab/rest"
import { getMethodLabelColor } from "~/helpers/rest/labelColoring" import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
import { WorkspaceService } from "~/services/workspace.service" import { WorkspaceService } from "~/services/workspace.service"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
const t = useI18n() const t = useI18n()
const interceptorService = useService(InterceptorService) const interceptorService = useService(KernelInterceptorService)
const methods = [ const methods = [
"GET", "GET",
@ -348,7 +348,7 @@ const newSendRequest = async () => {
platform.analytics?.logEvent({ platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN", type: "HOPP_REQUEST_RUN",
platform: "rest", platform: "rest",
strategy: interceptorService.currentInterceptorID.value!, strategy: interceptorService.current.value!.id,
workspaceType: workspaceService.currentWorkspace.value.type, workspaceType: workspaceService.currentWorkspace.value.type,
}) })

View file

@ -29,6 +29,26 @@
v-if="response.type === 'extension_error'" v-if="response.type === 'extension_error'"
class="flex-1" class="flex-1"
/> />
<HoppSmartPlaceholder
v-if="response.type === 'interceptor_error'"
:src="`/images/states/${colorMode.value}/upload_error.svg`"
:alt="
response.error?.humanMessage?.heading?.(t) || t('error.network_fail')
"
:heading="
response.error?.humanMessage?.heading?.(t) || t('error.network_fail')
"
:text="
response.error?.humanMessage?.description?.(t) ||
t('error.network_fail')
"
>
<template #body>
<AppKernelInterceptor
class="rounded border border-dividerLight p-2"
/>
</template>
</HoppSmartPlaceholder>
<HoppSmartPlaceholder <HoppSmartPlaceholder
v-if="response.type === 'network_fail'" v-if="response.type === 'network_fail'"
:src="`/images/states/${colorMode.value}/upload_error.svg`" :src="`/images/states/${colorMode.value}/upload_error.svg`"

View file

@ -1084,7 +1084,7 @@ const generateOAuthToken = async () => {
: { type: "request-tab", metadata: {} }, : { type: "request-tab", metadata: {} },
grant_type: auth.value.grantTypeInfo.grantType, grant_type: auth.value.grantTypeInfo.grantType,
} }
persistenceService.setLocalConfig( await persistenceService.setLocalConfig(
"oauth_temp_config", "oauth_temp_config",
JSON.stringify(authConfig) JSON.stringify(authConfig)
) )

View file

@ -45,11 +45,13 @@
import { computed, ref, watch } from "vue" import { computed, ref, watch } from "vue"
import { useI18n } from "@composables/i18n" import { useI18n } from "@composables/i18n"
import { useToast } from "~/composables/toast" import { useToast } from "~/composables/toast"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { parseBodyAsJSON } from "~/helpers/functional/json"
const interceptorService = useService(InterceptorService) const interceptorService = useService(KernelInterceptorService)
const t = useI18n() const t = useI18n()
@ -83,34 +85,27 @@ const disableImportCTA = computed(() => !hasURL.value || props.loading)
const urlFetchLogic = const urlFetchLogic =
props.fetchLogic ?? props.fetchLogic ??
async function (url: string) { async function (url: string) {
const res = await interceptorService.runRequest({ const { response } = interceptorService.execute({
id: Date.now(),
url: url, url: url,
transitional: { method: "GET",
forcedJSONParsing: false, version: "HTTP/1.1",
silentJSONParsing: false,
clarifyTimeoutError: true,
},
}) })
const response = await res.response const res = await response
if (E.isLeft(response)) { if (E.isLeft(res)) {
return E.left("REQUEST_FAILED") return E.left("REQUEST_FAILED")
} }
// convert ArrayBuffer to string const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
if (!(response.right.data instanceof ArrayBuffer)) {
return E.left("REQUEST_FAILED") if (O.isSome(responsePayload)) {
return E.right(responsePayload)
} }
try {
return E.right(
InterceptorService.convertArrayBufferToString(response.right.data)
)
} catch (e) {
return E.left("REQUEST_FAILED") return E.left("REQUEST_FAILED")
} }
}
async function fetchUrlData() { async function fetchUrlData() {
isFetchingUrl.value = true isFetchingUrl.value = true

View file

@ -0,0 +1,380 @@
<template>
<div class="flex flex-col space-y-1 w-full">
<div
class="flex items-center justify-between px-4 py-3 hover:accent-primaryLight rounded-md"
:class="{
'cursor-pointer': !isVendored,
'bg-accent text-accentContrast': isVendored,
}"
@click="!isVendored && connectToVendored()"
>
<div class="flex items-center gap-4">
<IconLucidePackage />
<div class="flex flex-col">
<span class="font-semibold uppercase">Hoppscotch</span>
<div class="flex items-center gap-1">
<span class="text-xs">On-prem</span>
<span class="text-xs"> app </span>
</div>
</div>
</div>
<IconLucideCheck v-if="isVendored" />
</div>
<div class="flex flex-col space-y-1">
<div
v-for="instance in recentInstances"
:key="instance.serverUrl"
class="flex items-center justify-between px-4 py-2 rounded-md group"
:class="{
'bg-accent text-accentContrast':
currentInstance &&
currentInstance.type === 'server' &&
currentInstance.serverUrl ===
instanceService.normalizeUrl(instance.serverUrl),
'hover:bg-primaryLight': !(
currentInstance &&
currentInstance.type === 'server' &&
currentInstance.serverUrl ===
instanceService.normalizeUrl(instance.serverUrl)
),
}"
>
<div
class="flex items-center gap-4 flex-1 cursor-pointer"
@click="
!isConnectedTo(instance.serverUrl) &&
connectToServer(instance.serverUrl)
"
>
<IconLucideServer />
<div class="flex flex-col">
<span
v-tippy="{
content: instance.serverUrl,
theme: 'tooltip',
}"
class="font-semibold uppercase"
>{{ getHostname(instance.displayName) }}</span
>
<div class="flex items-center gap-1">
<span v-if="isOnPrem(instance.serverUrl)" class="text-xs"
>On-prem</span
>
<span v-if="instance.version" class="text-xs">
v{{ instance.version }}
</span>
</div>
</div>
</div>
<div class="flex items-center">
<div class="w-8 flex justify-center">
<IconLucideCheck
v-if="isConnectedTo(instance.serverUrl)"
class="text-current"
/>
<HoppButtonSecondary
v-if="!isConnectedTo(instance.serverUrl)"
v-tippy="{
content: t('action.remove_instance') || 'Remove instance',
theme: 'tooltip',
}"
class="!p-0 ml-4 opacity-0 group-hover:opacity-100 transition-opacity"
:icon="IconLucideTrash"
@click.stop="
confirmRemove(instance.serverUrl, instance.displayName)
"
/>
</div>
</div>
</div>
</div>
<hr />
<HoppButtonSecondary
:label="t('instances.add_instance') || 'Add an instance'"
:icon="IconLucidePlus"
filled
outline
@click="
() => {
showAddModal = true
$emit('close-dropdown')
}
"
/>
<HoppSmartModal
v-if="showAddModal"
dialog
:title="t('instances.add_new') || 'Add New Instance'"
styles="sm:max-w-md"
@close="showAddModal = false"
>
<template #body>
<form class="flex flex-col space-y-4" @submit.prevent="handleConnect">
<div class="flex flex-col space-y-2">
<HoppSmartInput
v-model="newInstanceUrl"
:disabled="isConnecting"
placeholder="hoppscotch.company.com"
:error="!!connectionError"
type="text"
autofocus
styles="bg-primaryLight border-divider text-secondaryDark"
input-styles="floating-input peer w-full px-4 py-2 bg-primaryDark border border-divider rounded text-secondaryDark font-medium transition focus:border-dividerDark disabled:opacity-75"
@submit="handleConnect"
>
<template #prefix>
<IconLucideGlobe />
</template>
<HoppSmartInput>
<template #suffix>
<IconLucideCheck
v-if="
!isConnecting &&
!connectionError &&
newInstanceUrl &&
isValidUrl &&
!isCurrentUrl
"
class="text-green-500"
/>
<IconLucideAlertCircle
v-else-if="
!isConnecting && !connectionError && isCurrentUrl
"
class="text-amber-500"
/>
</template>
</HoppSmartInput>
</HoppSmartInput>
<span v-if="connectionError" class="text-red-500 text-tiny">
{{ connectionError }}
</span>
<span v-else-if="isCurrentUrl" class="text-amber-500 text-tiny">
{{
t("instances.already_connected") ||
"You are already connected to this instance"
}}
</span>
</div>
<HoppButtonPrimary
type="submit"
:disabled="isConnecting || !isValidUrl || isCurrentUrl"
:loading="isConnecting"
:label="t('action.connect')"
class="h-10"
/>
</form>
</template>
<template #footer>
<div class="flex justify-end w-full">
<HoppButtonSecondary
v-tippy="{
content: t('instances.clear_cached_bundles'),
theme: 'tooltip',
}"
:icon="IconLucideTrash2"
:label="t('action.clear_cache')"
:loading="isClearingCache"
:disabled="isClearingCache"
class="!text-red-500 hover:!text-red-600"
@click="handleClearCache"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartModal
v-if="showRemoveModal"
dialog
:title="t('instances.confirm_remove') || 'Confirm Removal'"
styles="sm:max-w-md"
@close="showRemoveModal = false"
>
<template #body>
<p>
{{
t("instances.remove_warning") ||
"Are you sure you want to remove this instance?"
}}
<span class="font-bold">
{{ confirmedRemoveDisplayName }}
</span>
</p>
</template>
<template #footer>
<div class="flex justify-end w-full space-x-2">
<HoppButtonSecondary
:label="t('action.cancel') || 'Cancel'"
outline
filled
@click="showRemoveModal = false"
/>
<HoppButtonPrimary
:label="t('action.remove') || 'Remove'"
filled
outline
@click="removeInstance(confirmedRemoveUrl)"
/>
</div>
</template>
</HoppSmartModal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from "vue"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import {
InstanceSwitcherService,
InstanceType,
} from "~/services/instance-switcher.service"
import IconLucideGlobe from "~icons/lucide/globe"
import IconLucideCheck from "~icons/lucide/check"
import IconLucideServer from "~icons/lucide/server"
import IconLucideTrash from "~icons/lucide/trash"
import IconLucideTrash2 from "~icons/lucide/trash-2"
import IconLucideAlertCircle from "~icons/lucide/alert-circle"
import IconLucidePlus from "~icons/lucide/plus"
import IconLucidePackage from "~icons/lucide/package"
const t = useI18n()
const instanceService = useService(InstanceSwitcherService)
const emit = defineEmits(["close-dropdown"])
const showAddModal = ref(false)
const newInstanceUrl = ref("")
const isClearingCache = ref(false)
const showRemoveModal = ref(false)
const confirmedRemoveUrl = ref("")
const confirmedRemoveDisplayName = ref("")
const confirmRemove = (url: string, displayName: string) => {
confirmedRemoveUrl.value = url
confirmedRemoveDisplayName.value = displayName || getHostname(url)
showRemoveModal.value = true
emit("close-dropdown")
}
const state = useReadonlyStream(
instanceService.getStateStream(),
instanceService.getCurrentState().value
)
const recentInstances = useReadonlyStream(
instanceService.getRecentInstancesStream(),
[]
)
const currentInstance = computed<InstanceType | null>(() => {
return state.value.status === "connected" ? state.value.instance : null
})
const isConnecting = computed(() => state.value.status === "connecting")
const connectionError = computed(() => {
return state.value.status === "error" ? state.value.message : null
})
const isVendored = computed(() => {
return currentInstance.value?.type === "vendored"
})
const isValidUrl = computed(() => {
if (!newInstanceUrl.value) return false
try {
const normalizedUrl = newInstanceUrl.value.startsWith("http")
? newInstanceUrl.value
: `http://${newInstanceUrl.value}`
const url = new URL(normalizedUrl)
console.info("url", url)
return true
} catch {
return false
}
})
const isCurrentUrl = computed(() => {
if (!newInstanceUrl.value) return false
if (currentInstance.value?.type !== "server") return false
try {
return instanceService.isCurrentlyConnectedTo(newInstanceUrl.value)
} catch {
return false
}
})
const isConnectedTo = (url: string): boolean => {
return instanceService.isCurrentlyConnectedTo(url)
}
const getHostname = (url: string): string => {
try {
if (!url.startsWith("http")) {
return url.toUpperCase()
}
const hostname = new URL(url).hostname
return hostname.toUpperCase()
} catch {
return url.toUpperCase()
}
}
const isOnPrem = (url: string): boolean => {
try {
const hostname = new URL(url.startsWith("http") ? url : `http://${url}`)
.hostname
return hostname !== "hoppscotch.com"
} catch {
return false
}
}
const connectToVendored = async () => {
if (isVendored.value) return
await instanceService.connectToVendoredInstance()
if (showAddModal.value) showAddModal.value = false
emit("close-dropdown")
}
const connectToServer = async (url: string) => {
await instanceService.connectToServerInstance(url)
emit("close-dropdown")
}
const removeInstance = async (url: string) => {
await instanceService.removeInstance(url)
showRemoveModal.value = false
}
const handleConnect = async () => {
if (!newInstanceUrl.value || !isValidUrl.value || isCurrentUrl.value) return
const success = await instanceService.connectToServerInstance(
newInstanceUrl.value
)
if (success) {
newInstanceUrl.value = ""
showAddModal.value = false
}
}
const handleClearCache = async () => {
if (isClearingCache.value) return
isClearingCache.value = true
await instanceService.clearCache()
isClearingCache.value = false
}
</script>

View file

@ -137,15 +137,15 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
}) })
) )
const defaultPreview = const defaultPreview =
persistenceService.getLocalConfig("lens_html_preview") === "true" (await persistenceService.getLocalConfig("lens_html_preview")) === "true"
const { previewFrame, previewEnabled, togglePreview } = usePreview( const { previewFrame, previewEnabled, togglePreview } = usePreview(
defaultPreview, defaultPreview,
responseBodyText responseBodyText
) )
const doTogglePreview = () => { const doTogglePreview = async () => {
persistenceService.setLocalConfig( await persistenceService.setLocalConfig(
"lens_html_preview", "lens_html_preview",
previewEnabled.value ? "false" : "true" previewEnabled.value ? "false" : "true"
) )

View file

@ -1,106 +1,661 @@
<template> <template>
<div class="py-4 space-y-4"> <div class="flex flex-col">
<div class="flex items-center space-x-2 py-4">
<h2 class="font-semibold flex-1 truncate">{{ selectedDomainDisplay }}</h2>
<HoppButtonSecondary
v-tippy="{
theme: 'tooltip',
content: t('settings.manage_domains_overrides'),
}"
:icon="IconSettings"
outline
class="rounded"
@click="showDomainModal = true"
/>
</div>
<div class="flex flex-col space-y-4">
<div class="flex items-center"> <div class="flex items-center">
<HoppSmartToggle <HoppSmartToggle
:on="allowSSLVerification" :on="domainSettings[selectedDomain]?.security?.verifyHost"
@change="allowSSLVerification = !allowSSLVerification" @change="toggleVerifyHost"
/> />
{{ t("agent.verify_ssl_certs") }} {{ t("settings.verify_host") }}
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="domainSettings[selectedDomain]?.security?.verifyPeer"
@change="toggleVerifyPeer"
/>
{{ t("settings.verify_peer") }}
</div> </div>
<div class="flex space-x-4"> <div class="flex space-x-4">
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconLucideFileBadge" :icon="IconFileBadge"
:label="'CA Certificates'" :label="t('settings.ca_certificates')"
outline outline
@click="showCACertificatesModal = true" @click="showCACertModal = true"
/> />
<HoppButtonSecondary <HoppButtonSecondary
:icon="IconLucideFileKey" :icon="IconFileKey"
:label="t('agent.client_certs')" :label="t('settings.client_certificates')"
outline outline
@click="showClientCertificatesModal = true" @click="showClientCertModal = true"
/> />
</div> </div>
<InterceptorsAgentModalNativeCACertificates
: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"> <div class="flex items-center">
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" /> <HoppSmartToggle
{{ t("agent.use_http_proxy") }} :on="!!domainSettings[selectedDomain]?.proxy"
@change="toggleProxy"
/>
{{ t("settings.proxy") }}
</div>
<p class="my-1 text-secondaryLight">
{{ t("settings.proxy_capabilities") }}
</p>
<div
v-if="domainSettings[selectedDomain]?.proxy"
class="flex flex-col space-y-2"
>
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.url"
:placeholder="' '"
:label="t('settings.proxy_url')"
input-styles="floating-input !border-0"
@update:model-value="updateProxyUrl"
/>
<p class="my-1 text-secondaryLight">
{{ t("settings.proxy_auth") }}
</p>
<div class="flex">
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.username"
:placeholder="' '"
:label="t('authorization.username')"
input-styles="floating-input !border-0"
@update:model-value="updateProxyUsername"
/>
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.password"
:placeholder="' '"
:label="t('authorization.password')"
input-styles="floating-input !border-0"
:type="showProxyPassword ? 'text' : 'password'"
@update:model-value="updateProxyPassword"
>
<template #button>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
showProxyPassword ? t('hide.password') : t('show.password')
"
:icon="showProxyPassword ? IconEye : IconEyeOff"
@click="showProxyPassword = !showProxyPassword"
/>
</template>
</HoppSmartInput>
</div>
</div>
</div> </div>
<HoppSmartModal
v-if="showDomainModal"
:title="t('settings.manage_domains_overrides')"
@close="showDomainModal = false"
>
<template #body>
<div class="space-y-4 p-4">
<div class="flex space-x-2">
<HoppSmartInput <HoppSmartInput
v-if="allowProxy" v-model="newDomain"
v-model="proxyURL" :placeholder="'example.com'"
:autofocus="false" class="flex-1"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
/> />
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', content: t('settings.add_domain') }"
:icon="IconPlus"
outline
class="rounded"
@click="addDomain"
/>
</div>
<div class="space-y-2">
<div
v-for="domain in domains"
:key="domain"
class="flex items-center justify-between p-2 rounded opacity-50 hover:bg-primaryLight hover:cursor-pointer hover:opacity-100"
:class="{ 'bg-primaryLight': domain === selectedDomain }"
@click="selectDomain(domain)"
>
<span class="py-2.5 truncate max-w-[80%]">{{
domain === "*" ? t("settings.global_defaults") : domain
}}</span>
<HoppButtonSecondary
v-if="domain !== '*'"
v-tippy="{
theme: 'tooltip',
content: t('settings.remove_domain'),
}"
:icon="IconTrash"
outline
class="rounded"
@click.stop="removeDomain(domain)"
/>
</div>
</div>
</div>
</template>
</HoppSmartModal>
<p class="my-1 text-secondaryLight"> <HoppSmartModal
{{ t("agent.proxy_capabilities") }} v-if="showClientCertModal"
dialog
:title="t('settings.client_certificates')"
@close="showClientCertModal = false"
>
<template #body>
<HoppSmartTabs v-model="certType">
<HoppSmartTab id="pem" :label="t('PEM')">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label>{{ t("settings.certificate") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.cert
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.cert?.name) ||
t('settings.select_file')
"
outline
@click="pickPEMCertificate"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{ t("settings.key") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.key
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.key?.name) ||
t('settings.select_file')
"
outline
@click="pickPEMKey"
/>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab id="pfx" :label="t('PFX')">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label>{{ t("settings.certificate") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pfx' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.data
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pfx' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.data?.name) ||
t('settings.select_file')
"
outline
@click="pickPFXCertificate"
/>
</div>
<div class="border border-divider rounded">
<HoppSmartInput
v-model="pfxPassword"
type="password"
:label="t('settings.password')"
input-styles="floating-input !border-0"
:placeholder="' '"
@update:model-value="updatePFXPassword"
/>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonPrimary
:label="t('action.done')"
:disabled="isClientCertIncomplete"
@click="showClientCertModal = false"
/>
<HoppButtonSecondary
:label="t('action.clear')"
@click="clearClientCerts"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartModal
v-if="showCACertModal"
dialog
:title="t('settings.ca_certificates')"
@close="showCACertModal = false"
>
<template #body>
<div class="flex flex-col space-y-4">
<ul class="mx-4 border border-divider rounded">
<li
v-for="(cert, index) in domainSettings[selectedDomain]?.security
?.certificates?.ca"
:key="index"
class="flex border-dividerDark px-2 items-center justify-between"
:class="{ 'border-t border-dividerDark': index !== 0 }"
>
<div class="truncate">{{ cert.name }}</div>
<div class="flex items-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="cert.include ? IconCheckCircle : IconCircle"
:title="
cert.include ? t('action.turn_off') : t('action.turn_on')
"
color="green"
@click="toggleCACertFromStore(index)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
:title="t('action.remove')"
@click="clearCACert(index)"
/>
</div>
</li>
</ul>
<div class="flex flex-col space-y-2 mx-4">
<HoppButtonSecondary
:icon="IconPlus"
:label="t('settings.add_cert_file')"
outline
@click="pickCACertificate"
/>
</div>
<p class="text-center text-secondaryLight">
{{ t("settings.ca_certificates_support") }}
</p> </p>
</div> </div>
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonPrimary
:label="t('action.done')"
@click="showCACertModal = false"
/>
</div>
</template>
</HoppSmartModal>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from "vue" import { ref, reactive, computed, onMounted } from "vue"
import { useI18n } from "@composables/i18n"
import IconLucideFileKey from "~icons/lucide/file-key"
import IconLucideFileBadge from "~icons/lucide/file-badge"
import { useService } from "dioc/vue" import { useService } from "dioc/vue"
import { import { useI18n } from "@composables/i18n"
RequestDef, import { useToast } from "@composables/toast"
AgentInterceptorService, import { useCertificatePicker } from "@composables/picker"
} from "~/platform/std/interceptors/agent" import { KernelInterceptorAgentStore } from "~/platform/std/kernel-interceptors/agent/store"
import { syncRef } from "@vueuse/core"
type RequestProxyInfo = RequestDef["proxy"] import IconTrash from "~icons/lucide/trash"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconFile from "~icons/lucide/file"
import IconPlus from "~icons/lucide/plus"
import IconEye from "~icons/lucide/eye"
import IconEyeOff from "~icons/lucide/eye-off"
import IconSettings from "~icons/lucide/settings"
import IconFileKey from "~icons/lucide/file-key"
import IconFileBadge from "~icons/lucide/file-badge"
const t = useI18n() const t = useI18n()
const toast = useToast()
const store = useService(KernelInterceptorAgentStore)
const agentInterceptorService = useService(AgentInterceptorService) const maskedAuthKey = ref("")
const allowSSLVerification = agentInterceptorService.validateCerts const selectedDomain = ref("*")
const domainSettings = reactive<Record<string, any>>({})
const showDomainModal = ref(false)
const showProxyPassword = ref(false)
const newDomain = ref("")
const domains = ref<string[]>(store.getDomains())
const showCACertificatesModal = ref(false) const showClientCertModal = ref(false)
const showClientCertificatesModal = ref(false) const showCACertModal = ref(false)
const isClientCertIncomplete = computed(() => {
const client =
domainSettings[selectedDomain.value]?.security?.certificates?.client
if (!client) return false
const allowProxy = ref(false) return client.kind === "pem"
const proxyURL = ref("") ? !client.cert || !client.key
: !client.data || !client.password
})
const proxyInfo = computed<RequestProxyInfo>({ const {
get() { certType,
if (allowProxy.value) { pfxPassword,
return { pickPEMCertificate,
url: proxyURL.value, pickPEMKey,
} pickPFXCertificate,
pickCACertificate,
} = useCertificatePicker({
async onPEMCertChange(file) {
if (!file) return
const cert = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
} }
return undefined updateDomainSettings({
security: {
certificates: {
client: {
kind: "pem",
cert,
key: domainSettings[selectedDomain.value]?.security?.certificates
?.client?.key,
}, },
set(newData) { },
if (newData) { },
allowProxy.value = true })
proxyURL.value = newData.url },
} else { async onPEMKeyChange(file) {
allowProxy.value = false if (!file) return
const key = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
} }
updateDomainSettings({
security: {
certificates: {
client: {
kind: "pem",
key,
cert: domainSettings[selectedDomain.value]?.security?.certificates
?.client?.cert,
},
},
},
})
},
async onPFXChange(file) {
if (!file) return
const data = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
updateDomainSettings({
security: {
certificates: {
client: {
kind: "pfx",
data,
password: pfxPassword.value,
},
},
},
})
},
async onCACertAdd(file) {
const cert = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
updateDomainSettings({
security: {
certificates: {
ca: [...currentCerts, cert],
},
},
})
}, },
}) })
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" }) const selectedDomainDisplay = computed(() =>
selectedDomain.value === "*"
? t("settings.global_defaults")
: selectedDomain.value
)
function addDomain() {
if (newDomain.value) {
const domain = newDomain.value.toLowerCase()
store.saveDomainSettings(domain, { version: "v1" })
domains.value.push(domain)
newDomain.value = ""
}
}
function removeDomain(domain: string) {
store.clearDomainSettings(domain)
domains.value = domains.value.filter((d) => d !== domain)
if (selectedDomain.value === domain) {
selectedDomain.value = "*"
}
}
function selectDomain(domain: string) {
selectedDomain.value = domain
if (!domainSettings[domain]) {
const settings = store.getDomainSettings(domain)
domainSettings[domain] = settings
}
showDomainModal.value = false
}
function updateDomainSettings(newSettings: any) {
const domain = selectedDomain.value
if (!domainSettings[domain]) {
domainSettings[domain] = { version: "v1" }
}
const currentSettings = domainSettings[domain]
domainSettings[domain] = {
...currentSettings,
...newSettings,
security: {
...currentSettings?.security,
...newSettings.security,
certificates: {
...currentSettings?.security?.certificates,
...newSettings.security?.certificates,
},
},
}
store.saveDomainSettings(domain, domainSettings[domain])
}
function toggleVerifyHost() {
updateDomainSettings({
security: {
verifyHost: !domainSettings[selectedDomain.value]?.security?.verifyHost,
},
})
}
function toggleVerifyPeer() {
updateDomainSettings({
security: {
verifyPeer: !domainSettings[selectedDomain.value]?.security?.verifyPeer,
},
})
}
function toggleProxy() {
updateDomainSettings({
proxy: domainSettings[selectedDomain.value]?.proxy
? undefined
: { url: "" },
})
}
function updateProxyUrl(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: { ...current, url: value },
})
}
function updateProxyUsername(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: {
...current,
auth: {
...(current?.auth ?? {}),
username: value,
},
},
})
}
function updateProxyPassword(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: {
...current,
auth: {
...(current?.auth ?? {}),
password: value,
},
},
})
}
function updatePFXPassword(value: string) {
const currentClient =
domainSettings[selectedDomain.value]?.security?.certificates?.client
if (currentClient?.kind !== "pfx") return
updateDomainSettings({
security: {
certificates: {
client: {
...currentClient,
password: value,
},
},
},
})
}
function toggleCACertFromStore(index: number) {
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
const newCerts = [...currentCerts]
newCerts[index] = {
...newCerts[index],
include: !newCerts[index].include,
}
updateDomainSettings({
security: {
certificates: {
ca: newCerts,
},
},
})
}
function clearCACert(index: number) {
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
const newCerts = [...currentCerts]
newCerts.splice(index, 1)
updateDomainSettings({
security: {
certificates: {
ca: newCerts.length ? newCerts : undefined,
},
},
})
}
function clearClientCerts() {
updateDomainSettings({
security: {
certificates: {
client: undefined,
},
},
})
}
async function updateMaskedAuthKey() {
if (!store.authKey.value) return
try {
const registration = await store.fetchRegistrationInfo()
maskedAuthKey.value = registration.auth_key_hash
} catch (e) {
toast.error(t("settings.registration_fetch_failed"))
}
}
onMounted(async () => {
const initialSettings = store.getDomainSettings("*")
domainSettings["*"] = initialSettings
if (store.authKey.value) {
await updateMaskedAuthKey()
}
})
</script> </script>

View file

@ -0,0 +1,179 @@
<template>
<div
v-if="isSelected"
class="flex flex-col items-left my-2 text-secondaryLight"
>
<div
v-if="
!store.authKey.value &&
(!store.isAgentRunning.value || !hasCheckedAgent)
"
class="flex flex-1 items-center space-x-2"
>
<div class="relative flex-1 border border-divider rounded p-2">
<span>{{ t("settings.agent_not_running_short") }}</span>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.retry')"
:icon="IconRefresh"
outline
class="rounded"
@click="handleAgentCheck"
/>
</div>
<div
v-else-if="!store.authKey.value && !hasInitiatedRegistration"
class="flex flex-1 items-center space-x-2"
>
<div
class="relative flex-1 border border-divider rounded p-2 text-accent"
>
<span>{{ t("settings.agent_running") }}</span>
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.register')"
:icon="IconPlus"
outline
class="rounded"
@click="initiateRegistration"
/>
</div>
<div
v-else-if="!store.authKey.value"
class="flex flex-1 items-center space-x-2"
>
<HoppSmartInput
v-model="registrationOTP"
:autofocus="false"
:placeholder="' '"
:disabled="isRegistering"
:label="t('settings.enter_otp')"
input-styles="input floating-input"
class="flex-1"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('action.confirm')"
:icon="IconCheck"
:loading="isRegistering"
outline
class="rounded"
@click="register"
/>
</div>
<div v-else class="flex relative flex-1 items-center space-x-2">
<label
class="text-secondaryLight text-tiny absolute -top-2 left-2 px-1 bg-primary"
>{{ t("settings.agent_registered") }}</label
>
<div
class="w-full p-2 border border-dividerLight rounded bg-primary text-secondaryDark cursor-text select-all"
>
{{ maskedAuthKey }}
</div>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="t('settings.agent_reset_registration')"
:icon="iconClear"
outline
class="rounded"
@click="resetRegistration"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue"
import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { KernelInterceptorAgentStore } from "~/platform/std/kernel-interceptors/agent/store"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import IconPlus from "~icons/lucide/plus"
import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconCheck from "~icons/lucide/check"
import IconRefresh from "~icons/lucide/refresh-cw"
const t = useI18n()
const toast = useToast()
const store = useService(KernelInterceptorAgentStore)
const interceptorService = useService(KernelInterceptorService)
const iconClear = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
IconRotateCCW,
1000
)
const isSelected = computed(
() => interceptorService.current.value?.id === "agent"
)
const hasInitiatedRegistration = ref(false)
const maskedAuthKey = ref("")
const hasCheckedAgent = ref(false)
const registrationOTP = ref(store.authKey.value ? null : "")
const isRegistering = ref(false)
async function handleAgentCheck() {
try {
await store.checkAgentStatus()
hasCheckedAgent.value = true
if (!store.isAgentRunning.value) {
toast.error(t("settings.agent_not_running"))
}
} catch (e) {
hasCheckedAgent.value = false
toast.error(t("settings.agent_check_failed"))
}
}
async function initiateRegistration() {
try {
await store.initiateRegistration()
hasInitiatedRegistration.value = true
} catch (e) {}
}
async function register() {
if (!registrationOTP.value) return
isRegistering.value = true
try {
await store.verifyRegistration(registrationOTP.value)
await updateMaskedAuthKey()
toast.success(t("settings.agent_registration_successful"))
registrationOTP.value = ""
} catch (e) {
} finally {
isRegistering.value = false
}
}
function resetRegistration() {
store.authKey.value = null
maskedAuthKey.value = ""
registrationOTP.value = ""
hasInitiatedRegistration.value = false
}
async function updateMaskedAuthKey() {
if (!store.authKey.value) return
try {
const registration = await store.fetchRegistrationInfo()
maskedAuthKey.value = registration.auth_key_hash
} catch (e) {}
}
onMounted(async () => {
if (store.authKey.value) {
await updateMaskedAuthKey()
}
})
</script>

View file

@ -12,29 +12,25 @@
{{ t("settings.extension_ver_not_reported") }} {{ t("settings.extension_ver_not_reported") }}
</span> </span>
</div> </div>
<div class="flex flex-col space-y-2 py-4"> <div class="flex gap-2 py-2 w-fit">
<span>
<HoppSmartItem <HoppSmartItem
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld" to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
blank blank
:icon="IconChrome" :icon="IconChrome"
label="Chrome" label="Chrome"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null" :info-icon="hasChromeExtInstalled ? IconCheckCircle : undefined"
:active-info-icon="hasChromeExtInstalled" :active-info-icon="hasChromeExtInstalled"
outline outline
/> />
</span>
<span>
<HoppSmartItem <HoppSmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch" to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank blank
:icon="IconFirefox" :icon="IconFirefox"
label="Firefox" label="Firefox"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null" :info-icon="hasFirefoxExtInstalled ? IconCheckCircle : undefined"
:active-info-icon="hasFirefoxExtInstalled" :active-info-icon="hasFirefoxExtInstalled"
outline outline
/> />
</span>
</div> </div>
</template> </template>

View file

@ -0,0 +1,64 @@
<template>
<div
v-if="isSelected && isNotAvailable"
class="flex flex-col items-left my-2 text-secondaryLight"
>
<div class="text-secondaryLight">
<span v-if="O.isSome(extensionVersion)">
{{
`${t("settings.extension_version")}: v${extensionVersion.value.major}.${
extensionVersion.value.minor
}`
}}
</span>
<span v-else>
{{ t("settings.extension_version") }}:
{{ t("settings.extension_ver_not_reported") }}
</span>
</div>
<div class="flex gap-2 py-2 w-fit">
<HoppSmartItem
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
blank
:icon="IconChrome"
label="Chrome"
outline
class="w-28"
/>
<HoppSmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank
:icon="IconFirefox"
label="Firefox"
outline
class="w-28"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue"
import { useService } from "dioc/vue"
import * as O from "fp-ts/Option"
import { useI18n } from "@composables/i18n"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { KernelInterceptorExtensionStore } from "~/platform/std/kernel-interceptors/extension/store"
import IconChrome from "~icons/brands/chrome"
import IconFirefox from "~icons/brands/firefox"
const t = useI18n()
const store = useService(KernelInterceptorExtensionStore)
const interceptorService = useService(KernelInterceptorService)
const isSelected = computed(
() => interceptorService.current.value?.id === "extension"
)
const isNotAvailable = computed(
() => store.getExtensionStatus() !== "available"
)
const extensionVersion = computed(() => store.getExtensionVersion())
</script>

View file

@ -0,0 +1,642 @@
<template>
<div class="flex flex-col">
<div class="flex items-center space-x-2 py-4">
<h2 class="font-semibold flex-1 truncate">{{ selectedDomainDisplay }}</h2>
<HoppButtonSecondary
v-tippy="{
theme: 'tooltip',
content: t('settings.manage_domains_overrides'),
}"
:icon="IconSettings"
outline
class="rounded"
@click="showDomainModal = true"
/>
</div>
<div class="flex flex-col space-y-4">
<div class="flex items-center">
<HoppSmartToggle
:on="domainSettings[selectedDomain]?.security?.verifyHost"
@change="toggleVerifyHost"
/>
{{ t("settings.verify_host") }}
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="domainSettings[selectedDomain]?.security?.verifyPeer"
@change="toggleVerifyPeer"
/>
{{ t("settings.verify_peer") }}
</div>
<div class="flex space-x-4">
<HoppButtonSecondary
:icon="IconFileBadge"
:label="t('settings.ca_certificates')"
outline
@click="showCACertModal = true"
/>
<HoppButtonSecondary
:icon="IconFileKey"
:label="t('settings.client_certificates')"
outline
@click="showClientCertModal = true"
/>
</div>
<div class="flex items-center">
<HoppSmartToggle
:on="!!domainSettings[selectedDomain]?.proxy"
@change="toggleProxy"
/>
{{ t("settings.proxy") }}
</div>
<p class="my-1 text-secondaryLight">
{{ t("settings.proxy_capabilities") }}
</p>
<div
v-if="domainSettings[selectedDomain]?.proxy"
class="flex flex-col space-y-2"
>
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.url"
:placeholder="' '"
:label="t('settings.proxy_url')"
input-styles="floating-input !border-0"
@update:model-value="updateProxyUrl"
/>
<p class="my-1 text-secondaryLight">
{{ t("settings.proxy_auth") }}
</p>
<div class="flex">
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.username"
:placeholder="' '"
:label="t('authorization.username')"
input-styles="floating-input !border-0"
@update:model-value="updateProxyUsername"
/>
<HoppSmartInput
:model-value="domainSettings[selectedDomain].proxy.password"
:placeholder="' '"
:label="t('authorization.password')"
input-styles="floating-input !border-0"
:type="showProxyPassword ? 'text' : 'password'"
@update:model-value="updateProxyPassword"
>
<template #button>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:title="
showProxyPassword ? t('hide.password') : t('show.password')
"
:icon="showProxyPassword ? IconEye : IconEyeOff"
@click="showProxyPassword = !showProxyPassword"
/>
</template>
</HoppSmartInput>
</div>
</div>
</div>
<HoppSmartModal
v-if="showDomainModal"
:title="t('settings.manage_domains_overrides')"
@close="showDomainModal = false"
>
<template #body>
<div class="space-y-4 p-4">
<div class="flex space-x-2">
<HoppSmartInput
v-model="newDomain"
:placeholder="'example.com'"
class="flex-1"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip', content: t('settings.add_domain') }"
:icon="IconPlus"
outline
class="rounded"
@click="addDomain"
/>
</div>
<div class="space-y-2">
<div
v-for="domain in domains"
:key="domain"
class="flex items-center justify-between p-2 rounded opacity-50 hover:bg-primaryLight hover:cursor-pointer hover:opacity-100"
:class="{ 'bg-primaryLight': domain === selectedDomain }"
@click="selectDomain(domain)"
>
<span class="py-2.5 truncate max-w-[80%]">{{
domain === "*" ? t("settings.global_defaults") : domain
}}</span>
<HoppButtonSecondary
v-if="domain !== '*'"
v-tippy="{
theme: 'tooltip',
content: t('settings.remove_domain'),
}"
:icon="IconTrash"
outline
class="rounded"
@click.stop="removeDomain(domain)"
/>
</div>
</div>
</div>
</template>
</HoppSmartModal>
<HoppSmartModal
v-if="showClientCertModal"
dialog
:title="t('settings.client_certificates')"
@close="showClientCertModal = false"
>
<template #body>
<HoppSmartTabs v-model="certType">
<HoppSmartTab id="pem" :label="t('PEM')">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label>{{ t("settings.certificate") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.cert
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.cert?.name) ||
t('settings.select_file')
"
outline
@click="pickPEMCertificate"
/>
</div>
<div class="flex flex-col space-y-2">
<label>{{ t("settings.key") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.key
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pem' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.key?.name) ||
t('settings.select_file')
"
outline
@click="pickPEMKey"
/>
</div>
</div>
</HoppSmartTab>
<HoppSmartTab id="pfx" :label="t('PFX')">
<div class="p-4 space-y-4">
<div class="flex flex-col space-y-2">
<label>{{ t("settings.certificate") }}</label>
<HoppButtonSecondary
:icon="
domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pfx' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.data
? IconFile
: IconPlus
"
:label="
(domainSettings[selectedDomain]?.security?.certificates
?.client?.kind === 'pfx' &&
domainSettings[selectedDomain]?.security?.certificates
?.client?.data?.name) ||
t('settings.select_file')
"
outline
@click="pickPFXCertificate"
/>
</div>
<div class="border border-divider rounded">
<HoppSmartInput
v-model="pfxPassword"
type="password"
:label="t('settings.password')"
input-styles="floating-input !border-0"
:placeholder="' '"
@update:model-value="updatePFXPassword"
/>
</div>
</div>
</HoppSmartTab>
</HoppSmartTabs>
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonPrimary
:label="t('action.done')"
:disabled="isClientCertIncomplete"
@click="showClientCertModal = false"
/>
<HoppButtonSecondary
:label="t('action.clear')"
@click="clearClientCerts"
/>
</div>
</template>
</HoppSmartModal>
<HoppSmartModal
v-if="showCACertModal"
dialog
:title="t('settings.ca_certificates')"
@close="showCACertModal = false"
>
<template #body>
<div class="flex flex-col space-y-4">
<ul class="mx-4 border border-divider rounded">
<li
v-for="(cert, index) in domainSettings[selectedDomain]?.security
?.certificates?.ca"
:key="index"
class="flex border-dividerDark px-2 items-center justify-between"
:class="{ 'border-t border-dividerDark': index !== 0 }"
>
<div class="truncate">{{ cert.name }}</div>
<div class="flex items-center">
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="cert.include ? IconCheckCircle : IconCircle"
:title="
cert.include ? t('action.turn_off') : t('action.turn_on')
"
color="green"
@click="toggleCACertFromStore(index)"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
:icon="IconTrash"
:title="t('action.remove')"
@click="clearCACert(index)"
/>
</div>
</li>
</ul>
<div class="flex flex-col space-y-2 mx-4">
<HoppButtonSecondary
:icon="IconPlus"
:label="t('settings.add_cert_file')"
outline
@click="pickCACertificate"
/>
</div>
<p class="text-center text-secondaryLight">
{{ t("settings.ca_certificates_support") }}
</p>
</div>
</template>
<template #footer>
<div class="flex justify-end">
<HoppButtonPrimary
:label="t('action.done')"
@click="showCACertModal = false"
/>
</div>
</template>
</HoppSmartModal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useService } from "dioc/vue"
import { useI18n } from "@composables/i18n"
import { useCertificatePicker } from "@composables/picker"
import { KernelInterceptorNativeStore } from "~/platform/std/kernel-interceptors/native/store"
import IconTrash from "~icons/lucide/trash"
import IconCheckCircle from "~icons/lucide/check-circle"
import IconCircle from "~icons/lucide/circle"
import IconFile from "~icons/lucide/file"
import IconPlus from "~icons/lucide/plus"
import IconEye from "~icons/lucide/eye"
import IconEyeOff from "~icons/lucide/eye-off"
import IconSettings from "~icons/lucide/settings"
import IconFileKey from "~icons/lucide/file-key"
import IconFileBadge from "~icons/lucide/file-badge"
const t = useI18n()
const store = useService(KernelInterceptorNativeStore)
const selectedDomain = ref("*")
const domainSettings = reactive<Record<string, any>>({})
const showDomainModal = ref(false)
const showProxyPassword = ref(false)
const newDomain = ref("")
const domains = ref<string[]>(store.getDomains())
const showClientCertModal = ref(false)
const showCACertModal = ref(false)
const isClientCertIncomplete = computed(() => {
const client =
domainSettings[selectedDomain.value]?.security?.certificates?.client
if (!client) return false
return client.kind === "pem"
? !client.cert || !client.key
: !client.data || !client.password
})
const {
certType,
pfxPassword,
pickPEMCertificate,
pickPEMKey,
pickPFXCertificate,
pickCACertificate,
} = useCertificatePicker({
async onPEMCertChange(file) {
if (!file) return
const cert = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
updateDomainSettings({
security: {
certificates: {
client: {
kind: "pem",
cert,
key: domainSettings[selectedDomain.value]?.security?.certificates
?.client?.key,
},
},
},
})
},
async onPEMKeyChange(file) {
if (!file) return
const key = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
updateDomainSettings({
security: {
certificates: {
client: {
kind: "pem",
key,
cert: domainSettings[selectedDomain.value]?.security?.certificates
?.client?.cert,
},
},
},
})
},
async onPFXChange(file) {
if (!file) return
const data = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
updateDomainSettings({
security: {
certificates: {
client: {
kind: "pfx",
data,
password: pfxPassword.value,
},
},
},
})
},
async onCACertAdd(file) {
const cert = {
include: true,
name: file.name,
size: file.size,
lastModified: file.lastModified,
content: new Uint8Array(await file.arrayBuffer()),
}
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
updateDomainSettings({
security: {
certificates: {
ca: [...currentCerts, cert],
},
},
})
},
})
const selectedDomainDisplay = computed(() =>
selectedDomain.value === "*"
? t("settings.global_defaults")
: selectedDomain.value
)
function addDomain() {
if (newDomain.value) {
const domain = newDomain.value.toLowerCase()
store.saveDomainSettings(domain, { version: "v1" })
domains.value.push(domain)
newDomain.value = ""
}
}
function removeDomain(domain: string) {
store.clearDomainSettings(domain)
domains.value = domains.value.filter((d) => d !== domain)
if (selectedDomain.value === domain) {
selectedDomain.value = "*"
}
}
function selectDomain(domain: string) {
selectedDomain.value = domain
if (!domainSettings[domain]) {
const settings = store.getDomainSettings(domain)
domainSettings[domain] = settings
}
showDomainModal.value = false
}
function updateDomainSettings(newSettings: any) {
const domain = selectedDomain.value
if (!domainSettings[domain]) {
domainSettings[domain] = { version: "v1" }
}
const currentSettings = domainSettings[domain]
domainSettings[domain] = {
...currentSettings,
...newSettings,
security: {
...currentSettings?.security,
...newSettings.security,
certificates: {
...currentSettings?.security?.certificates,
...newSettings.security?.certificates,
},
},
}
store.saveDomainSettings(domain, domainSettings[domain])
}
function toggleVerifyHost() {
updateDomainSettings({
security: {
verifyHost: !domainSettings[selectedDomain.value]?.security?.verifyHost,
},
})
}
function toggleVerifyPeer() {
updateDomainSettings({
security: {
verifyPeer: !domainSettings[selectedDomain.value]?.security?.verifyPeer,
},
})
}
function toggleProxy() {
updateDomainSettings({
proxy: domainSettings[selectedDomain.value]?.proxy
? undefined
: { url: "" },
})
}
function updateProxyUrl(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: { ...current, url: value },
})
}
function updateProxyUsername(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: {
...current,
auth: {
...(current?.auth ?? {}),
username: value,
},
},
})
}
function updateProxyPassword(value: string) {
const current = domainSettings[selectedDomain.value]?.proxy
updateDomainSettings({
proxy: {
...current,
auth: {
...(current?.auth ?? {}),
password: value,
},
},
})
}
function updatePFXPassword(value: string) {
const currentClient =
domainSettings[selectedDomain.value]?.security?.certificates?.client
if (currentClient?.kind !== "pfx") return
updateDomainSettings({
security: {
certificates: {
client: {
...currentClient,
password: value,
},
},
},
})
}
function toggleCACertFromStore(index: number) {
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
const newCerts = [...currentCerts]
newCerts[index] = {
...newCerts[index],
include: !newCerts[index].include,
}
updateDomainSettings({
security: {
certificates: {
ca: newCerts,
},
},
})
}
function clearCACert(index: number) {
const currentCerts =
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
const newCerts = [...currentCerts]
newCerts.splice(index, 1)
updateDomainSettings({
security: {
certificates: {
ca: newCerts.length ? newCerts : undefined,
},
},
})
}
function clearClientCerts() {
updateDomainSettings({
security: {
certificates: {
client: undefined,
},
},
})
}
onMounted(() => {
const initialSettings = store.getDomainSettings("*")
domainSettings["*"] = initialSettings
})
</script>

View file

@ -10,13 +10,14 @@
</div> </div>
<div class="flex items-center space-x-2 py-4"> <div class="flex items-center space-x-2 py-4">
<HoppSmartInput <HoppSmartInput
v-model="PROXY_URL" v-model="proxyUrl"
:autofocus="false" :autofocus="false"
styles="flex-1" styles="flex-1"
placeholder=" " :placeholder="' '"
:label="t('settings.proxy_url')" :label="t('settings.proxy_url')"
input-styles="input floating-input" input-styles="input floating-input"
:disabled="!proxyEnabled" :disabled="!enabled"
@change="updateProxyUrl"
/> />
<HoppButtonSecondary <HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }" v-tippy="{ theme: 'tooltip' }"
@ -24,50 +25,61 @@
:icon="clearIcon" :icon="clearIcon"
outline outline
class="rounded" class="rounded"
@click="resetProxy" @click="resetSettings"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue"
import { refAutoReset } from "@vueuse/core" import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue"
import { useI18n } from "~/composables/i18n" import { useI18n } from "~/composables/i18n"
import { useSetting } from "~/composables/settings" import { useToast } from "~/composables/toast"
import { useReadonlyStream } from "~/composables/stream"
import { getDefaultProxyUrl } from "~/helpers/proxyUrl"
import { platform } from "~/platform"
import { KernelInterceptorProxyStore } from "~/platform/std/kernel-interceptors/proxy/store"
import { ProxyKernelInterceptorService } from "~/platform/std/kernel-interceptors/proxy/index"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import IconRotateCCW from "~icons/lucide/rotate-ccw" import IconRotateCCW from "~icons/lucide/rotate-ccw"
import IconCheck from "~icons/lucide/check" import IconCheck from "~icons/lucide/check"
import { useToast } from "~/composables/toast"
import { computed, watch } from "vue"
import { useService } from "dioc/vue"
import { InterceptorService } from "~/services/interceptor.service"
import { proxyInterceptor } from "~/platform/std/interceptors/proxy"
import { useReadonlyStream } from "~/composables/stream"
import { platform } from "~/platform"
import { getDefaultProxyUrl } from "~/helpers/proxyUrl"
const t = useI18n() const t = useI18n()
const toast = useToast() const toast = useToast()
const interceptorService = useService(InterceptorService) const store = useService(KernelInterceptorProxyStore)
const interceptorService = useService(KernelInterceptorService)
const proxyInterceptorService = useService(ProxyKernelInterceptorService)
const PROXY_URL = useSetting("PROXY_URL") const proxyUrl = ref("")
const currentUser = useReadonlyStream( const currentUser = useReadonlyStream(
platform.auth.getCurrentUserStream(), platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser() platform.auth.getCurrentUser()
) )
async function updateProxyUrl() {
await store.updateSettings({ proxyUrl: proxyUrl.value })
toast.success(t("state.saved"))
}
watch( watch(
() => currentUser.value, () => currentUser.value,
async () => { async () => {
if (!currentUser.value) { if (!currentUser.value) {
PROXY_URL.value = await getDefaultProxyUrl() proxyUrl.value = await getDefaultProxyUrl()
} }
} }
) )
const proxyEnabled = computed(
() => const enabled = computed(
interceptorService.currentInterceptorID.value === () => interceptorService.getCurrentId() === proxyInterceptorService.id
proxyInterceptor.interceptorID
) )
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>( const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
@ -75,9 +87,16 @@ const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
1000 1000
) )
const resetProxy = async () => { async function resetSettings() {
PROXY_URL.value = await getDefaultProxyUrl() await store.resetSettings()
const settings = store.getSettings()
proxyUrl.value = settings.proxyUrl
clearIcon.value = IconCheck clearIcon.value = IconCheck
toast.success(`${t("state.cleared")}`) toast.success(t("state.cleared"))
} }
onMounted(async () => {
const settings = store.getSettings()
proxyUrl.value = settings.proxyUrl
})
</script> </script>

View file

@ -68,7 +68,7 @@ export function useDownloadResponse(
const dataToWrite = responseBody.value const dataToWrite = responseBody.value
// TODO: Look at the mime type and determine extension ? // TODO: Look at the mime type and determine extension ?
const result = await platform.io.saveFileWithDialog({ const result = await platform.kernelIO.saveFileWithDialog({
data: dataToWrite, data: dataToWrite,
contentType: contentType, contentType: contentType,
suggestedFilename: filename, suggestedFilename: filename,

View file

@ -0,0 +1,138 @@
import { ref, reactive, computed } from "vue"
import { useFileDialog } from "@vueuse/core"
export interface CertFiles {
pem_cert: File | null
pem_key: File | null
pfx: File | null
ca: File[]
}
export interface CertPickerOptions {
onPEMCertChange?: (file: File | null) => void
onPEMKeyChange?: (file: File | null) => void
onPFXChange?: (file: File | null) => void
onCACertAdd?: (file: File) => void
onCACertRemove?: (index: number) => void
}
export function useCertificatePicker(options: CertPickerOptions = {}) {
const certFiles = reactive<CertFiles>({
pem_cert: null,
pem_key: null,
pfx: null,
ca: [],
})
const certType = ref<"pem" | "pfx">("pem")
const pfxPassword = ref("")
const pemCertPicker = useFileDialog({
accept: ".pem,.crt",
reset: true,
multiple: false,
})
const pemKeyPicker = useFileDialog({
accept: ".pem,.key",
reset: true,
multiple: false,
})
const pfxPicker = useFileDialog({
accept: ".pfx,.p12",
reset: true,
multiple: false,
})
const caCertPicker = useFileDialog({
accept: ".pem,.crt",
reset: true,
multiple: false,
})
function pickPEMCertificate() {
pemCertPicker.onChange((files) => {
const selectedFile = files?.item(0)
if (selectedFile) {
certFiles.pem_cert = selectedFile
certFiles.pfx = null
options.onPEMCertChange?.(selectedFile)
}
pemCertPicker.reset()
})
pemCertPicker.open()
}
function pickPEMKey() {
pemKeyPicker.onChange((files) => {
const selectedFile = files?.item(0)
if (selectedFile) {
certFiles.pem_key = selectedFile
certFiles.pfx = null
options.onPEMKeyChange?.(selectedFile)
}
pemKeyPicker.reset()
})
pemKeyPicker.open()
}
function pickPFXCertificate() {
pfxPicker.onChange((files) => {
const selectedFile = files?.item(0)
if (selectedFile) {
certFiles.pfx = selectedFile
certFiles.pem_cert = null
certFiles.pem_key = null
options.onPFXChange?.(selectedFile)
}
pfxPicker.reset()
})
pfxPicker.open()
}
function pickCACertificate() {
caCertPicker.onChange((files) => {
const selectedFile = files?.item(0)
if (selectedFile) {
certFiles.ca.push(selectedFile)
options.onCACertAdd?.(selectedFile)
}
caCertPicker.reset()
})
caCertPicker.open()
}
function removeCACertificate(index: number) {
certFiles.ca.splice(index, 1)
options.onCACertRemove?.(index)
}
function reset() {
certFiles.pem_cert = null
certFiles.pem_key = null
certFiles.pfx = null
certFiles.ca = []
pfxPassword.value = ""
certType.value = "pem"
}
const isValidCertConfig = computed(() =>
certType.value === "pem"
? !!(certFiles.pem_cert && certFiles.pem_key)
: !!(certFiles.pfx && pfxPassword.value)
)
return {
certFiles,
certType,
pfxPassword,
pickPEMCertificate,
pickPEMKey,
pickPFXCertificate,
pickCACertificate,
removeCACertificate,
reset,
isValidCertConfig,
}
}

View file

@ -5,14 +5,18 @@ import { getService } from "~/modules/dioc"
import { PersistenceService } from "~/services/persistence" import { PersistenceService } from "~/services/persistence"
import { version as hoppscotchCommonPkgVersion } from "./../../package.json" import { version as hoppscotchCommonPkgVersion } from "./../../package.json"
export function useWhatsNewDialog() { export async function useWhatsNewDialog() {
const persistenceService = getService(PersistenceService) const persistenceService = getService(PersistenceService)
const versionFromLocalStorage = persistenceService.getLocalConfig("hopp_v") const versionFromLocalStorage =
await persistenceService.getLocalConfig("hopp_v")
// Set new entry `hopp_v` under `localStorage` if not present // Set new entry `hopp_v` under `localStorage` if not present
if (!versionFromLocalStorage) { if (!versionFromLocalStorage) {
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion) await persistenceService.setLocalConfig(
"hopp_v",
hoppscotchCommonPkgVersion
)
return return
} }
@ -53,7 +57,7 @@ export function useWhatsNewDialog() {
}, 10000) }, 10000)
} }
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion) await persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
} }
async function getReleaseNotes(v: string): Promise<string | undefined> { async function getReleaseNotes(v: string): Promise<string | undefined> {

View file

@ -261,9 +261,8 @@ export function runRESTRequest$(
variables: finalEnvsWithNonEmptyValues, variables: finalEnvsWithNonEmptyValues,
}) })
const [stream, cancelRun] = createRESTNetworkRequestStream( const [stream, cancelRun] =
await effectiveRequest await createRESTNetworkRequestStream(effectiveRequest)
)
cancelFunc = cancelRun cancelFunc = cancelRun
const subscription = stream const subscription = stream

View file

@ -67,6 +67,7 @@ export type HoppAction =
| "response.save" // Save response | "response.save" // Save response
| "response.save-as-example" // Save response as example | "response.save-as-example" // Save response as example
| "modals.login.toggle" // Login to Hoppscotch | "modals.login.toggle" // Login to Hoppscotch
| "modals.instance-switcher.toggle" // Switch Hoppscotch instances (self-hosted)
| "history.clear" // Clear REST History | "history.clear" // Clear REST History
| "user.login" // Login to Hoppscotch | "user.login" // Login to Hoppscotch
| "user.logout" // Log out of Hoppscotch | "user.logout" // Log out of Hoppscotch

View file

@ -3,7 +3,7 @@ import { md5 } from "js-md5"
import { getService } from "~/modules/dioc" import { getService } from "~/modules/dioc"
import { getI18n } from "~/modules/i18n" import { getI18n } from "~/modules/i18n"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
export interface DigestAuthParams { export interface DigestAuthParams {
username: string username: string
@ -85,11 +85,15 @@ export async function fetchInitialDigestAuthInfo(
const t = getI18n() const t = getI18n()
try { try {
const service = getService(InterceptorService) const interceptorService = getService(KernelInterceptorService)
const initialResponse = await service.runRequest({ const exec = await interceptorService.execute({
id: Date.now(),
url, url,
method, method,
}).response version: "HTTP/1.1",
})
const initialResponse = await exec.response
if (E.isLeft(initialResponse)) { if (E.isLeft(initialResponse)) {
const initialFetchFailureReason = const initialFetchFailureReason =

View file

@ -0,0 +1,228 @@
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
import { StoreFile, RelayRequest } from "@hoppscotch/kernel"
export type InputDomainSetting = {
version: "v1"
security?: {
certificates?: {
client?:
| {
kind: "pem"
cert?: StoreFile
key?: StoreFile
}
| {
kind: "pfx"
data?: StoreFile
password?: string
}
ca?: StoreFile[]
}
verifyHost?: boolean
verifyPeer?: boolean
}
proxy?: {
url: string
auth?: {
username?: string
password?: string
}
certificates?: {
ca?: StoreFile[]
client?:
| {
kind: "pem"
cert?: StoreFile
key?: StoreFile
}
| {
kind: "pfx"
data?: StoreFile
password?: string
}
}
}
}
const convertStoreFile = (file: StoreFile): O.Option<Uint8Array> =>
file.include === false ? O.none : O.some(file.content)
const convertClientCert = (
cert?:
| {
kind: "pem"
cert?: StoreFile
key?: StoreFile
}
| {
kind: "pfx"
data?: StoreFile
password?: string
}
): O.Option<
NonNullable<
NonNullable<
Pick<RelayRequest, "proxy" | "security">["security"]
>["certificates"]
>["client"]
> => {
if (!cert) return O.none
switch (cert.kind) {
case "pem": {
const certContent = pipe(
O.fromNullable(cert.cert),
O.chain(convertStoreFile)
)
const keyContent = pipe(
O.fromNullable(cert.key),
O.chain(convertStoreFile)
)
return pipe(
O.Do,
O.bind("cert", () => certContent),
O.bind("key", () => keyContent),
O.map(({ cert, key }) => ({
kind: "pem" as const,
cert,
key,
}))
)
}
case "pfx": {
const dataContent = pipe(
O.fromNullable(cert.data),
O.chain(convertStoreFile)
)
return pipe(
O.Do,
O.bind("data", () => dataContent),
O.bind("password", () => O.fromNullable(cert.password)),
O.map(({ data, password }) => ({
kind: "pfx" as const,
data,
password,
}))
)
}
}
}
const convertCaCerts = (certs?: StoreFile[]): O.Option<Uint8Array[]> =>
pipe(
O.fromNullable(certs),
O.chain((certs: StoreFile[]) => {
const converted = certs
.map(convertStoreFile)
.filter(O.isSome)
.map((opt) => opt.value)
return converted.length > 0 ? O.some(converted) : O.none
})
)
const convertSecurity = (
security?: InputDomainSetting["security"]
): O.Option<Pick<RelayRequest, "proxy" | "security">["security"]> =>
pipe(
O.fromNullable(security),
O.chain((security) => {
return pipe(
O.fromNullable(security.certificates),
O.chain((certificates) =>
pipe(
O.Do,
O.bind("client", () => convertClientCert(certificates.client)),
O.bind("ca", () => convertCaCerts(certificates.ca)),
O.map((convertedCerts) => ({
certificates: {
client: convertedCerts.client,
ca: convertedCerts.ca,
},
// Default to `false` if not explicitly set
verifyHost: security.verifyHost ?? false,
verifyPeer: security.verifyPeer ?? false,
}))
)
),
// If no certificates but security object exists, still return verify settings
O.alt(() =>
O.some({
verifyHost: security.verifyHost ?? false,
verifyPeer: security.verifyPeer ?? false,
})
)
)
}),
// If no security object at all, return default settings
O.alt(() =>
O.some({
verifyHost: false,
verifyPeer: false,
})
)
)
const convertProxy = (
proxy?: InputDomainSetting["proxy"]
): O.Option<Pick<RelayRequest, "proxy" | "security">["proxy"]> =>
pipe(
O.fromNullable(proxy),
O.chain((proxy) => {
if (!proxy.url) return O.none
const auth = proxy.auth && {
username: proxy.auth.username || "",
password: proxy.auth.password || "",
}
return pipe(
O.fromNullable(proxy.certificates),
O.chain((certificates) =>
pipe(
O.Do,
O.bind("client", () => convertClientCert(certificates.client)),
O.bind("ca", () => convertCaCerts(certificates.ca)),
O.map((certs) => ({
client: certs.client,
ca: certs.ca,
}))
)
),
O.fold(
() =>
O.some({
url: proxy.url,
...(auth && { auth }),
}),
(certificates) =>
O.some({
url: proxy.url,
...(auth && { auth }),
certificates,
})
)
)
})
)
export const convertDomainSetting = (
input: InputDomainSetting
): E.Either<Error, Pick<RelayRequest, "proxy" | "security">> => {
if (input.version !== "v1") {
return E.left(new Error("Invalid version"))
}
const security = convertSecurity(input.security)
const proxy = convertProxy(input.proxy)
const result: Pick<RelayRequest, "proxy" | "security"> = {
proxy: O.isSome(proxy) ? proxy.value : undefined,
security: O.isSome(security) ? security.value : undefined,
}
return E.right(result)
}

View file

@ -0,0 +1,13 @@
import { HoppRESTRequestVariables } from "@hoppscotch/data"
import { pipe } from "fp-ts/function"
import * as A from "fp-ts/Array"
export const filterActiveToRecord = (
headers: HoppRESTRequestVariables
): Record<string, string> =>
pipe(
headers,
A.filter((header) => header.active),
A.map((header): [string, string] => [header.key, header.value]),
(entries) => Object.fromEntries(entries)
)

View file

@ -1,5 +1,10 @@
import * as O from "fp-ts/Option" import * as O from "fp-ts/Option"
import { flow } from "fp-ts/function" import * as E from "fp-ts/Either"
import { pipe, flow } from "fp-ts/function"
import { MediaType, RelayResponseBody } from "@hoppscotch/kernel"
import { decodeToString } from "~/helpers/functional/parse"
type SafeParseJSON = { type SafeParseJSON = {
(str: string, convertToArray: true): O.Option<Array<unknown>> (str: string, convertToArray: true): O.Option<Array<unknown>>
@ -26,3 +31,21 @@ export const safeParseJSON: SafeParseJSON = (str, convertToArray = false) =>
* @returns If string is a JSON string * @returns If string is a JSON string
*/ */
export const isJSON = flow(safeParseJSON, O.isSome) export const isJSON = flow(safeParseJSON, O.isSome)
export const parseBytesToJSON = <T>(content: Uint8Array): O.Option<T> =>
pipe(
content,
decodeToString,
E.chain(parseJSONAs<T>),
E.fold(() => O.none, O.some)
)
export const parseJSONAs = <T>(str: string): E.Either<Error, T> =>
E.tryCatch(() => JSON.parse(str) as T, E.toError)
export const parseBodyAsJSON = <T>(body: RelayResponseBody): O.Option<T> =>
pipe(
O.fromNullable(body.mediaType),
O.filter((type) => type.includes(MediaType.APPLICATION_JSON)),
O.chain(() => parseBytesToJSON<T>(body.body))
)

View file

@ -0,0 +1,7 @@
import * as E from "fp-ts/Either"
export const decodeToString = (content: Uint8Array): E.Either<Error, string> =>
E.tryCatch(
() => new TextDecoder("utf-8").decode(content).replace(/\x00/g, ""),
E.toError
)

View file

@ -0,0 +1,69 @@
import { pipe } from "fp-ts/function"
import * as O from "fp-ts/Option"
import * as E from "fp-ts/Either"
import * as R from "fp-ts/Record"
import { cloneDeep } from "lodash-es"
import { useSetting } from "~/composables/settings"
import type { RelayRequest } from "@hoppscotch/kernel"
const isEncoded = (value: string): boolean =>
pipe(
E.tryCatch(
() => value !== decodeURIComponent(value),
() => false
),
E.getOrElse(() => false)
)
const encodeParam = (value: string): string =>
pipe(
O.some(value),
O.filter((v) => !isEncoded(v)),
O.map(encodeURIComponent),
O.getOrElse(() => value)
)
const processParams = (
params: Record<string, string>
): Record<string, string> => {
const encodeMode = useSetting("ENCODE_MODE").value
const needsEncoding = (v: string) =>
/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]+/.test(v)
return pipe(
params,
R.map((value) =>
encodeMode === "enable" || (encodeMode === "auto" && needsEncoding(value))
? encodeParam(value)
: value
)
)
}
const updateUrl = (
url: string,
params: Record<string, string>
): E.Either<Error, string> =>
pipe(
E.tryCatch(
() => new URL(url),
(e) => new Error(`Invalid URL: ${e}`)
),
E.map((u) => {
Object.entries(processParams(params)).forEach(([k, v]) =>
u.searchParams.append(k, v)
)
return decodeURIComponent(u.toString())
})
)
export const preProcessRelayRequest = (req: RelayRequest): RelayRequest =>
pipe(cloneDeep(req), (req) =>
req.params
? pipe(
updateUrl(req.url, req.params),
E.map((url) => ({ ...req, url, params: {} })),
E.getOrElse(() => req)
)
: req
)

View file

@ -25,9 +25,13 @@ import { getI18n } from "~/modules/i18n"
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history" import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { GQLTabService } from "~/services/tab/graphql" import { GQLTabService } from "~/services/tab/graphql"
import { MediaType, content, Method, RelayRequest } from "@hoppscotch/kernel"
import { GQLRequest } from "~/helpers/kernel/gql/request"
import { GQLResponse } from "~/helpers/kernel/gql/response"
const GQL_SCHEMA_POLL_INTERVAL = 7000 const GQL_SCHEMA_POLL_INTERVAL = 7000
type ConnectionRequestOptions = { type ConnectionRequestOptions = {
@ -111,49 +115,44 @@ export const connection = reactive<Connection>({
}) })
export const schema = computed(() => connection.schema) export const schema = computed(() => connection.schema)
export const subscriptionState = computed(() => { export const subscriptionState = computed(() =>
return connection.subscriptionState.get(currentTabID.value) connection.subscriptionState.get(currentTabID.value)
}) )
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">() export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
export const schemaString = computed(() => { export const schemaString = computed(() => {
if (!connection.schema) return "" if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
return ""
return printSchema(connection.schema) return printSchema(connection.schema)
}) })
export const queryFields = computed(() => { export const queryFields = computed(() => {
if (!connection.schema) return [] if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
return []
const fields = connection.schema.getQueryType()?.getFields() const fields = connection.schema.getQueryType()?.getFields()
if (!fields) return [] return fields ? Object.values(fields) : []
return Object.values(fields)
}) })
export const mutationFields = computed(() => { export const mutationFields = computed(() => {
if (!connection.schema) return [] if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
return []
const fields = connection.schema.getMutationType()?.getFields() const fields = connection.schema.getMutationType()?.getFields()
if (!fields) return [] return fields ? Object.values(fields) : []
return Object.values(fields)
}) })
export const subscriptionFields = computed(() => { export const subscriptionFields = computed(() => {
if (!connection.schema) return [] if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
return []
const fields = connection.schema.getSubscriptionType()?.getFields() const fields = connection.schema.getSubscriptionType()?.getFields()
if (!fields) return [] return fields ? Object.values(fields) : []
return Object.values(fields)
}) })
export const graphqlTypes = computed(() => { export const graphqlTypes = computed(() => {
if (!connection.schema) return [] if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
return []
const typeMap = connection.schema.getTypeMap() const typeMap = connection.schema.getTypeMap()
const queryTypeName = connection.schema.getQueryType()?.name ?? "" const queryTypeName = connection.schema.getQueryType()?.name ?? ""
const mutationTypeName = connection.schema.getMutationType()?.name ?? "" const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
const subscriptionTypeName = const subscriptionTypeName =
@ -193,7 +192,6 @@ export const connect = async (
const poll = async () => { const poll = async () => {
try { try {
await getSchema(options) await getSchema(options)
// polling for schema
if (connection.state !== "CONNECTED") connection.state = "CONNECTED" if (connection.state !== "CONNECTED") connection.state = "CONNECTED"
timeoutSubscription = setTimeout(() => { timeoutSubscription = setTimeout(() => {
poll() poll()
@ -201,7 +199,6 @@ export const connect = async (
} catch (error) { } catch (error) {
connection.state = "ERROR" connection.state = "ERROR"
// Show an error toast if the introspection attempt failed and not while sending a request
if (!isRunGQLOperation) { if (!isRunGQLOperation) {
toast.error(t("graphql.connection_error_http")) toast.error(t("graphql.connection_error_http"))
} }
@ -232,10 +229,6 @@ export const reset = () => {
const getSchema = async (options: ConnectionRequestOptions) => { const getSchema = async (options: ConnectionRequestOptions) => {
try { try {
const introspectionQuery = JSON.stringify({
query: getIntrospectionQuery(),
})
const { url, request, inheritedHeaders, inheritedAuth } = options const { url, request, inheritedHeaders, inheritedAuth } = options
const headers = request?.headers || [] const headers = request?.headers || []
@ -271,62 +264,60 @@ const getSchema = async (options: ConnectionRequestOptions) => {
.filter((item) => item.active && item.key !== "") .filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value)) .forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = { const kernelRequest: RelayRequest = {
method: "POST", id: Date.now(),
url: options.url, url: options.url,
method: "POST" as Method,
version: "HTTP/1.1",
headers: { headers: {
...finalHeaders, ...finalHeaders,
"content-type": "application/json", "content-type": "application/json",
}, },
data: introspectionQuery, content: content.json(
{ query: getIntrospectionQuery() },
MediaType.APPLICATION_JSON
),
} }
const interceptorService = getService(InterceptorService) const kernelInterceptorService = getService(KernelInterceptorService)
const { response } = kernelInterceptorService.execute(kernelRequest)
const res = await interceptorService.runRequest(reqOptions).response const res = await response
if (E.isLeft(res)) { if (E.isLeft(res)) {
connection.state = "ERROR" connection.state = "ERROR"
if ( if (res.left !== "cancellation" && typeof res.left === "object") {
res.left !== "cancellation" &&
res.left.error === "NO_PW_EXT_HOOK" &&
res.left.humanMessage
) {
connection.error = { connection.error = {
type: res.left.error, type: res.left.error?.kind || "error",
message: (t: ReturnType<typeof getI18n>) => message: (t: ReturnType<typeof getI18n>) => {
res.left.humanMessage.description(t), if (res.left !== "cancellation" && typeof res.left === "object") {
return (
res.left.humanMessage?.description(t) ||
t("graphql.connection_error_http")
)
}
return "Unknown"
},
component: res.left.component, component: res.left.component,
} }
} }
throw new Error(res.left.toString()) throw new Error(
} typeof res.left === "string" ? res.left : res.left.error.message
)
if (res.right.status !== 200) {
connection.state = "ERROR"
connection.error = {
type: "HTTP_ERROR",
message: (t: ReturnType<typeof getI18n>) =>
t("graphql.connection_error_http"),
component: undefined,
}
throw new Error("Failed to fetch schema. Status: " + res.right.status)
} }
const data = res.right const data = res.right
// HACK : Temporary trailing null character issue from the extension fix const decoder = new TextDecoder("utf-8")
const response = new TextDecoder("utf-8") const responseText = decoder.decode(data.body.body)
.decode(data.data as any)
.replace(/\0+$/, "")
const introspectResponse = JSON.parse(response) const introspectResponse = JSON.parse(responseText)
const schema = buildClientSchema(introspectResponse.data) const schemaData = buildClientSchema(introspectResponse.data)
connection.schema = schema connection.schema = schemaData
connection.error = null connection.error = null
} catch (e: any) { } catch (e: any) {
console.error(e) console.error(e)
@ -380,6 +371,15 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
const { authHeaders, authParams } = await generateAuthHeader(url, auth) const { authHeaders, authParams } = await generateAuthHeader(url, auth)
let finalUrl = url
if (Object.keys(authParams).length > 0) {
const urlObj = new URL(url)
for (const [key, value] of Object.entries(authParams)) {
urlObj.searchParams.append(key, value)
}
finalUrl = urlObj.toString()
}
runHeaders.forEach((header) => { runHeaders.forEach((header) => {
if (header.active && header.key !== "") { if (header.active && header.key !== "") {
finalHeaders[header.key] = header.value finalHeaders[header.key] = header.value
@ -387,74 +387,91 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
}) })
Object.assign(finalHeaders, authHeaders) Object.assign(finalHeaders, authHeaders)
const parsedVariables = JSON.parse(variables || "{}")
const params: Record<string, string> = {}
headers headers
.filter((item) => item.active && item.key !== "") .filter((item) => item.active && item.key !== "")
.forEach(({ key, value }) => (finalHeaders[key] = value)) .forEach(({ key, value }) => (finalHeaders[key] = value))
const reqOptions = { const gqlRequest: HoppGQLRequest = {
method: "POST", v: 8,
url, name: options.name || "Untitled Request",
headers: { url: finalUrl,
...finalHeaders, headers: request.headers,
"content-type": "application/json",
},
data: JSON.stringify({
query, query,
variables: parsedVariables, variables,
operationName, auth: request.auth as HoppGQLAuth,
}),
params: {
...params,
...authParams,
},
} }
if (operationType === "subscription") { if (operationType === "subscription") {
return runSubscription(options, finalHeaders) return runSubscription(options, finalHeaders)
} }
const interceptorService = getService(InterceptorService) try {
const result = await interceptorService.runRequest(reqOptions).response const kernelRequest = await GQLRequest.toRequest(gqlRequest)
if (operationName) {
if (kernelRequest.content?.kind === "json") {
const content = kernelRequest.content.content as any
content.operationName = operationName
kernelRequest.content.content = content
}
}
const kernelInterceptorService = getService(KernelInterceptorService)
const { response } = kernelInterceptorService.execute(kernelRequest)
const result = await response
if (E.isLeft(result)) { if (E.isLeft(result)) {
if (result.left !== "cancellation" && typeof result.left === "object") {
connection.error = {
type: result.left.error?.kind || "error",
message: (t: ReturnType<typeof getI18n>) => {
if ( if (
result.left !== "cancellation" && result.left !== "cancellation" &&
result.left.error === "NO_PW_EXT_HOOK" && typeof result.left === "object"
result.left.humanMessage
) { ) {
connection.error = { return (
type: result.left.error, result.left.humanMessage?.description(t) ||
message: (t: ReturnType<typeof getI18n>) => t("graphql.operation_error")
result.left.humanMessage.description(t), )
}
return "Unknown"
},
component: result.left.component, component: result.left.component,
} }
} }
throw new Error(result.left.toString())
throw new Error(
typeof result.left === "string"
? result.left
: result.left.error.message
)
} }
const res = result.right const relayResponse = result.right
// HACK: Temporary trailing null character issue from the extension fix const parsedResponse = await GQLResponse.toResponse(relayResponse, options)
const responseText = new TextDecoder("utf-8")
.decode(res.data as any)
.replace(/\0+$/, "")
if (parsedResponse.type === "error") {
throw new Error(parsedResponse.error.message)
}
gqlMessageEvent.value = parsedResponse
addQueryToHistory(options, parsedResponse.data)
return parsedResponse.data
} catch (error: any) {
gqlMessageEvent.value = { gqlMessageEvent.value = {
type: "response", type: "error",
time: Date.now(), error: {
operationName: operationName ?? "query", type: "network_error",
data: responseText, message: error.message || "An unknown error occurred",
rawQuery: options, },
operationType,
} }
addQueryToHistory(options, responseText) throw error
}
return responseText
} }
const generateAuthHeader = async ( const generateAuthHeader = async (

View file

@ -16,7 +16,7 @@ export const initializeDownloadFile = async (
const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0] const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0]
const result = await platform.io.saveFileWithDialog({ const result = await platform.kernelIO.saveFileWithDialog({
data: contentsJSON, data: contentsJSON,
contentType: "application/json", contentType: "application/json",
suggestedFilename: `${fileName}.json`, suggestedFilename: `${fileName}.json`,

View file

@ -9,7 +9,7 @@ export const exportTestResults = async (testResults: HoppTestResult) => {
const fileName = url.split("/").pop()!.split("#")[0].split("?")[0] const fileName = url.split("/").pop()!.split("#")[0].split("?")[0]
const result = await platform.io.saveFileWithDialog({ const result = await platform.kernelIO.saveFileWithDialog({
data: contentsJSON, data: contentsJSON,
contentType: "application/json", contentType: "application/json",
suggestedFilename: `${fileName}.json`, suggestedFilename: `${fileName}.json`,

View file

@ -2,14 +2,16 @@ import UrlImport from "~/components/importExport/ImportExportSteps/UrlImport.vue
import { defineStep } from "~/composables/step-components" import { defineStep } from "~/composables/step-components"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { z } from "zod" import { z } from "zod"
import { v4 as uuidv4 } from "uuid" import { v4 as uuidv4 } from "uuid"
import { Ref } from "vue" import { Ref } from "vue"
import { getService } from "~/modules/dioc" import { getService } from "~/modules/dioc"
import { InterceptorService } from "~/services/interceptor.service" import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { parseBodyAsJSON } from "~/helpers/functional/json"
const interceptorService = getService(InterceptorService) const interceptorService = getService(KernelInterceptorService)
export function GistSource(metadata: { export function GistSource(metadata: {
caption: string caption: string
@ -48,31 +50,27 @@ export function GistSource(metadata: {
} }
const fetchGistFromUrl = async (url: string) => { const fetchGistFromUrl = async (url: string) => {
const res = await interceptorService.runRequest({ const { response } = interceptorService.execute({
id: Date.now(),
url: `https://api.github.com/gists/${url.split("/").pop()}`, url: `https://api.github.com/gists/${url.split("/").pop()}`,
method: "GET",
version: "HTTP/1.1",
headers: { headers: {
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
}, },
}) })
const response = await res.response const res = await response
if (E.isLeft(response)) { if (E.isLeft(res)) {
return E.left("REQUEST_FAILED") return E.left("REQUEST_FAILED")
} }
// convert ArrayBuffer to string const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
if (!(response.right.data instanceof ArrayBuffer)) {
return E.left("REQUEST_FAILED") if (O.isSome(responsePayload)) {
return E.right(responsePayload)
} }
try {
return E.right(
JSON.parse(
InterceptorService.convertArrayBufferToString(response.right.data)
)
)
} catch (e) {
return E.left("REQUEST_FAILED") return E.left("REQUEST_FAILED")
}
} }

View file

@ -0,0 +1,353 @@
import { describe, expect, it } from "vitest"
import { GQLResponse } from "../gql/response"
import { GQLRequest } from "../gql/request"
import { RESTResponse } from "../rest/response"
import { RESTRequest } from "../rest/request"
import { HoppRESTRequest } from "@hoppscotch/data"
import { RunQueryOptions } from "~/helpers/graphql/connection"
import { MediaType } from "@hoppscotch/kernel"
import { HoppGQLRequest } from "@hoppscotch/data"
import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL"
import { RelayResponse } from "@hoppscotch/kernel"
describe("GraphQL Response Transformation", () => {
const baseOptions: RunQueryOptions = {
url: "https://api.example.com/graphql",
headers: [],
query: "",
variables: "",
auth: { authType: "none", authActive: true },
operationName: "TestQuery",
operationType: "query",
}
it("successfully transforms a valid GraphQL response with data", async () => {
const json = JSON.stringify({ data: { hello: "world" } })
const mockResponse: RelayResponse = {
status: 200,
headers: {},
body: {
mediaType: "application/json",
body: new Uint8Array(Buffer.from(json)),
},
meta: {
timing: {
start: 100,
end: 200,
},
},
}
const options = {
...baseOptions,
query: "query TestQuery { hello }",
}
const result = await GQLResponse.toResponse(
mockResponse as RelayResponse,
options
)
expect(result).toHaveProperty("type", "response")
if (result.type === "response") {
expect(result.operationName).toBe("TestQuery")
expect(result.time).toBe(100)
expect(JSON.parse(result.data)).toHaveProperty("data.hello", "world")
}
})
it("successfully transforms a GraphQL response with errors", async () => {
const mockResponse: RelayResponse = {
status: 200,
headers: {},
body: {
mediaType: "application/json",
body: new Uint8Array(
Buffer.from(
JSON.stringify({
errors: [
{ message: "Field 'invalid' does not exist" },
{ message: "Syntax error" },
],
})
)
),
},
meta: {
timing: {
start: 0,
end: 150,
},
},
}
const options = {
...baseOptions,
query: "query TestQuery { invalid }",
}
const result = await GQLResponse.toResponse(
mockResponse as RelayResponse,
options
)
expect(result).toHaveProperty("type", "response")
if (result.type === "response") {
const parsedData = JSON.parse(result.data)
expect(parsedData.errors).toHaveLength(2)
expect(parsedData.errors[0].message).toBe(
"Field 'invalid' does not exist"
)
}
})
it("returns transform error for non-GraphQL JSON response", async () => {
const mockResponse: RelayResponse = {
status: 200,
headers: {},
body: {
mediaType: "application/json",
body: new Uint8Array(
Buffer.from(
JSON.stringify({
someField: "not a graphql response",
})
)
),
},
}
const options = {
...baseOptions,
query: "query TestQuery { hello }",
}
const result = await GQLResponse.toResponse(
mockResponse as RelayResponse,
options
)
expect(result).toHaveProperty("type", "error")
if (result.type === "error") {
expect(result.error.message).toBe("Invalid GraphQL response structure")
}
})
})
describe("GraphQL Request Transformation", () => {
const baseRequest: HoppGQLRequest = {
v: 8,
name: "Test Query",
url: "https://api.example.com/graphql",
headers: [],
query: "",
variables: "",
auth: { authType: "none", authActive: true },
}
it("transforms a basic GraphQL request correctly", async () => {
const request: HoppGQLRequest = {
...baseRequest,
query: "query { hello }",
}
const result = await GQLRequest.toRequest(request)
expect(result).toMatchObject({
url: "https://api.example.com/graphql",
method: "POST",
version: "HTTP/1.1",
headers: { "content-type": "application/json" },
auth: { kind: "none" },
content: {
kind: "json",
content: { query: "query { hello }", variables: undefined },
mediaType: "application/json",
},
})
expect(result.content?.mediaType).toBe(MediaType.APPLICATION_JSON)
expect(result.content?.content).toBeDefined()
if (result.content?.body) {
const parsedBody =
typeof result.content.body === "string"
? JSON.parse(result.content.body)
: JSON.parse(new TextDecoder().decode(result.content.body))
expect(parsedBody).toEqual({
query: request.query,
variables: undefined,
})
}
})
it("properly parses and includes variables when provided", async () => {
const request: HoppGQLRequest = {
...baseRequest,
query: "query($id: ID!) { item(id: $id) { name } }",
variables: '{ "id": "123" }',
}
const result = await GQLRequest.toRequest(request)
expect(result.content?.kind).toBe("json")
expect(result.content?.content).toBeDefined()
if (result.content?.content) {
expect(result.content?.content).toEqual({
query: request.query,
variables: { id: "123" },
})
}
})
it("throws error for invalid JSON in variables", async () => {
const request: HoppGQLRequest = {
...baseRequest,
query: "query($id: ID!) { item(id: $id) { name } }",
variables: "{ invalid json }",
}
await expect(GQLRequest.toRequest(request)).rejects.toThrow("Invalid JSON")
})
})
describe("REST Response Transformation", () => {
it("transforms a successful REST response", async () => {
const mockResponse = {
body: {
body: new Uint8Array([72, 101, 108, 108, 111]),
},
headers: {
"content-type": "text/plain",
"x-custom": "test",
},
status: 200,
statusText: "OK",
meta: {
timing: {
start: 100,
end: 200,
},
size: {
total: 5,
},
},
}
const originalRequest: HoppRESTRequest = {
v: "11",
endpoint: "https://api.example.com",
name: "Test Request",
method: "GET",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
auth: { authType: "none", authActive: true },
body: { contentmediaType: null, body: null },
requestVariables: [],
responses: {},
}
const result = await RESTResponse.toResponse(
mockResponse as any,
originalRequest
)
expect(result).toHaveProperty("type", "success")
if (result.type === "success") {
expect(result.statusCode).toBe(200)
expect(result.meta.responseDuration).toBe(100)
expect(result.meta.responseSize).toBe(5)
expect(result.headers).toHaveLength(2)
}
})
it("returns transform error for invalid response body", async () => {
const mockResponse = {
body: {
body: "invalid body format",
},
}
const originalRequest: HoppRESTRequest = {
v: "11",
endpoint: "https://api.example.com",
name: "Test Request",
method: "GET",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
auth: { authType: "none", authActive: true },
body: { contentmediaType: null, body: null },
requestVariables: [],
responses: {},
}
const result = await RESTResponse.toResponse(
mockResponse as any,
originalRequest
)
expect(result).toHaveProperty("type", "fail")
if (result.type === "fail") {
expect(result.error.type).toBe("transform_error")
expect(result.error.message).toBe("Invalid response body format")
}
})
})
describe("REST Request Transformation", () => {
const baseEffectiveRequest: EffectiveHoppRESTRequest = {
v: "11",
name: "Test Request",
method: "GET",
endpoint: "https://api.example.com",
effectiveFinalURL: "https://api.example.com",
params: [],
headers: [],
preRequestScript: "",
testScript: "",
auth: { authType: "none", authActive: true },
body: { contentmediaType: null, body: null },
requestVariables: [],
responses: {},
effectiveFinalHeaders: [],
effectiveFinalParams: [],
effectiveFinalBody: null,
effectiveFinalRequestVariables: [],
}
it("transforms a basic REST request correctly", async () => {
const result = await RESTRequest.toRequest(baseEffectiveRequest)
expect(result).toMatchObject({
url: baseEffectiveRequest.effectiveFinalURL,
method: "GET",
headers: {},
params: {},
auth: { kind: "none" },
})
})
it("includes auth parameters when basic auth is active", async () => {
const request: EffectiveHoppRESTRequest = {
...baseEffectiveRequest,
auth: {
authType: "basic",
authActive: true,
username: "testuser",
password: "testpass",
},
}
const result = await RESTRequest.toRequest(request)
expect(result.auth).toMatchObject({
kind: "basic",
username: "testuser",
password: "testpass",
})
})
})

View file

@ -0,0 +1,286 @@
import { HoppRESTRequest, HoppRESTAuthOAuth2 } from "@hoppscotch/data"
import { AuthType } from "@hoppscotch/kernel"
import * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import * as TE from "fp-ts/TaskEither"
import { flow, pipe } from "fp-ts/function"
type HoppAuth = HoppRESTRequest["auth"]
type OAuth2GrantType = HoppRESTAuthOAuth2["grantTypeInfo"]
type GrantType = Extract<AuthType, { kind: "oauth2" }>["grantType"]
export const defaultAuth: AuthType = { kind: "none" }
const isAuthActive = (auth: HoppAuth): boolean =>
auth.authActive && auth.authType !== "none" && auth.authType !== "inherit"
const Guards = {
basic: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "basic" } =>
auth.authType === "basic"
)
),
bearer: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "bearer" } =>
auth.authType === "bearer"
)
),
apiKey: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "api-key" } =>
auth.authType === "api-key"
)
),
aws: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "aws-signature" } =>
auth.authType === "aws-signature"
)
),
digest: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "digest" } =>
auth.authType === "digest"
)
),
oauth2: flow(
O.fromPredicate(
(auth: HoppAuth): auth is HoppAuth & { authType: "oauth-2" } =>
auth.authType === "oauth-2"
)
),
grants: {
authCode: flow(
O.fromPredicate(
(
g: OAuth2GrantType
): g is Extract<OAuth2GrantType, { grantType: "AUTHORIZATION_CODE" }> =>
g.grantType === "AUTHORIZATION_CODE"
)
),
clientCreds: flow(
O.fromPredicate(
(
g: OAuth2GrantType
): g is Extract<OAuth2GrantType, { grantType: "CLIENT_CREDENTIALS" }> =>
g.grantType === "CLIENT_CREDENTIALS"
)
),
password: flow(
O.fromPredicate(
(
g: OAuth2GrantType
): g is Extract<OAuth2GrantType, { grantType: "PASSWORD" }> =>
g.grantType === "PASSWORD"
)
),
implicit: flow(
O.fromPredicate(
(
g: OAuth2GrantType
): g is Extract<OAuth2GrantType, { grantType: "IMPLICIT" }> =>
g.grantType === "IMPLICIT"
)
),
},
}
type AuthProcessor<T extends HoppAuth = HoppAuth> = (
auth: T
) => E.Either<Error, AuthType>
const Processors: {
basic: AuthProcessor
bearer: AuthProcessor
apiKey: AuthProcessor
aws: AuthProcessor
digest: AuthProcessor
oauth2: {
processGrant: (
grant: OAuth2GrantType
) => E.Either<Error, AuthType["grantType"]>
process: AuthProcessor
}
} = {
basic: flow(
Guards.basic,
O.map((a) => ({
kind: "basic" as const,
username: a.username,
password: a.password,
})),
E.fromOption(() => new Error("Invalid basic auth"))
),
bearer: flow(
Guards.bearer,
O.map((a) => ({
kind: "bearer" as const,
token: a.token,
})),
E.fromOption(() => new Error("Invalid bearer auth"))
),
apiKey: flow(
Guards.apiKey,
O.map((a) => ({
kind: "apikey" as const,
key: a.key,
value: a.value,
in: a.addTo === "HEADERS" ? "header" : "query",
})),
E.fromOption(() => new Error("Invalid API key auth"))
),
aws: flow(
Guards.aws,
O.map((a) => ({
kind: "aws" as const,
accessKey: a.accessKey,
secretKey: a.secretKey,
region: a.region,
service: a.serviceName,
sessionToken: a.serviceToken,
in: a.addTo === "HEADERS" ? "header" : "query",
})),
E.fromOption(() => new Error("Invalid AWS auth"))
),
digest: flow(
Guards.digest,
O.map((a) => ({
kind: "digest" as const,
username: a.username,
password: a.password,
realm: a.realm,
nonce: a.nonce,
algorithm: a.algorithm === "MD5" ? "MD5" : "SHA-256",
qop: a.qop,
nc: a.nc,
cnonce: a.cnonce,
opaque: a.opaque,
})),
E.fromOption(() => new Error("Invalid digest auth"))
),
oauth2: {
processGrant: (
grant: OAuth2GrantType
): E.Either<Error, AuthType["grantType"]> =>
pipe(
grant,
(g) =>
pipe(
O.none as O.Option<GrantType>,
O.alt(() =>
pipe(
Guards.grants.authCode(g),
O.map(
(g): GrantType => ({
kind: "authorization_code",
authEndpoint: g.authEndpoint,
tokenEndpoint: g.tokenEndpoint,
clientId: g.clientID,
clientSecret: g.clientSecret,
})
)
)
),
O.alt(() =>
pipe(
Guards.grants.clientCreds(g),
O.map(
(g): GrantType => ({
kind: "client_credentials",
tokenEndpoint: g.authEndpoint,
clientId: g.clientID,
clientSecret: g.clientSecret,
})
)
)
),
O.alt(() =>
pipe(
Guards.grants.password(g),
O.map(
(g): GrantType => ({
kind: "password",
tokenEndpoint: g.authEndpoint,
username: g.username,
password: g.password,
})
)
)
),
O.alt(() =>
pipe(
Guards.grants.implicit(g),
O.map(
(g): GrantType => ({
kind: "implicit",
authEndpoint: g.authEndpoint,
clientId: g.clientID,
})
)
)
)
),
E.fromOption(() => new Error("Invalid grant type"))
),
process: flow(
Guards.oauth2,
E.fromOption(() => new Error("Invalid OAuth2 auth")),
E.chain((a) =>
pipe(
Processors.oauth2.processGrant(a.grantTypeInfo),
E.map((grantType) => ({
kind: "oauth2" as const,
accessToken: a.grantTypeInfo.token,
refreshToken:
"refreshToken" in a.grantTypeInfo
? a.grantTypeInfo.refreshToken
: undefined,
grantType,
}))
)
)
),
},
}
const getProcessor = (
auth: HoppAuth
): O.Option<(auth: HoppAuth) => E.Either<Error, AuthType>> =>
pipe(
O.fromNullable(auth.authType),
O.chain((type) => {
switch (type) {
case "basic":
return O.some(Processors.basic)
case "bearer":
return O.some(Processors.bearer)
case "api-key":
return O.some(Processors.apiKey)
case "aws-signature":
return O.some(Processors.aws)
case "digest":
return O.some(Processors.digest)
case "oauth-2":
return O.some(Processors.oauth2.process)
default:
return O.none
}
})
)
export const transformAuth = (auth: HoppAuth): TE.TaskEither<Error, AuthType> =>
pipe(
auth,
O.fromPredicate(isAuthActive),
O.chain(getProcessor),
O.map((processor) => processor(auth)),
O.getOrElse(() => E.right(defaultAuth)),
TE.fromEither
)

View file

@ -0,0 +1,192 @@
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import * as O from "fp-ts/Option"
import * as A from "fp-ts/Array"
import { pipe } from "fp-ts/function"
import { parseJSONAs } from "~/helpers/functional/json"
import { ContentType, MediaType, content } from "@hoppscotch/kernel"
import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL"
type FormDataValue = {
kind: "file"
filename: string
contentType: string
data: Uint8Array
}
const Processors = {
json: {
process: (body: string): E.Either<Error, ContentType> =>
pipe(
parseJSONAs<unknown>(body),
E.map((json) => content.json(json, MediaType.APPLICATION_JSON)),
E.orElse(() => E.right(content.text(body, MediaType.TEXT_PLAIN)))
),
},
multipart: {
processFile: (entry: {
key: string
file: Blob
contentType?: string
}): TE.TaskEither<Error, { key: string; value: FormDataValue[] }> =>
pipe(
TE.tryCatch(
() => entry.file.arrayBuffer(),
() => new Error("File read failed")
),
TE.map((buffer) => ({
key: entry.key,
value: [
{
kind: "file",
filename:
entry.file instanceof File ? entry.file.name : "unknown",
contentType:
entry.contentType ??
(entry.file instanceof File
? entry.file.type
: "application/octet-stream"),
data: new Uint8Array(buffer),
},
],
}))
),
process: (formData: FormData): TE.TaskEither<Error, ContentType> =>
pipe(
TE.tryCatch(
async () => {
const entries = [] as {
key: string
file: Blob
contentType?: string
}[]
// @ts-expect-error: `formData.entries` does exist but isn't visible,
// see `"lib": ["ESNext", "DOM"],` in `tsconfig.json`
for (const [key, value] of formData.entries()) {
if (value instanceof Blob) {
entries.push({
key,
file: value,
contentType: value.type || undefined,
})
}
}
return entries
},
() => new Error("FormData processing failed")
),
TE.chain((entries) =>
pipe(
entries,
A.traverse(TE.ApplicativePar)(Processors.multipart.processFile),
TE.map(
A.reduce(
new Map<string, FormDataValue[]>(),
(acc, { key, value }) => {
acc.set(key, value)
return acc
}
)
)
)
),
TE.map((entries) => content.multipart(entries))
),
},
binary: {
process: (file: Blob): TE.TaskEither<Error, ContentType> =>
pipe(
TE.tryCatch(
() => file.arrayBuffer(),
() => new Error("Binary read failed")
),
TE.map((buffer) =>
content.binary(
new Uint8Array(buffer),
file.type || "application/octet-stream",
file instanceof File ? file.name : "unknown"
)
)
),
},
urlencoded: {
process: (body: string): E.Either<Error, ContentType> =>
pipe(
E.right(body),
E.map((contents) => {
return content.urlencoded(contents)
})
),
},
xml: {
process: (body: string): E.Either<Error, ContentType> =>
E.right(content.xml(body, MediaType.APPLICATION_XML)),
},
text: {
process: (body: string): E.Either<Error, ContentType> =>
E.right(content.text(body, MediaType.TEXT_PLAIN)),
},
}
const getProcessor = (contentType: string) => {
switch (contentType) {
case "application/json":
case "application/ld+json":
case "application/hal+json":
case "application/vnd.api+json":
return Processors.json.process
case "application/xml":
case "text/xml":
return Processors.xml.process
case "application/x-www-form-urlencoded":
return Processors.urlencoded.process
case "text/html":
case "text/plain":
return Processors.text.process
default:
return Processors.text.process
}
}
export const transformContent = (
request: EffectiveHoppRESTRequest
): TE.TaskEither<Error, O.Option<ContentType>> => {
const { body, effectiveFinalBody } = request
if (!body.contentType || !effectiveFinalBody) {
return TE.right(O.none)
}
switch (body.contentType) {
case "multipart/form-data":
if (!(effectiveFinalBody instanceof FormData)) {
return TE.right(O.none)
}
return pipe(
Processors.multipart.process(effectiveFinalBody),
TE.map(O.some)
)
case "application/octet-stream":
if (!(effectiveFinalBody instanceof Blob)) {
return TE.right(O.none)
}
return pipe(Processors.binary.process(effectiveFinalBody), TE.map(O.some))
default:
if (typeof effectiveFinalBody !== "string") {
return TE.right(O.none)
}
return pipe(
TE.fromEither(getProcessor(body.contentType)(effectiveFinalBody)),
TE.map(O.some)
)
}
}

View file

@ -0,0 +1,2 @@
export { transformContent } from "./content"
export { transformAuth } from "./auth"

View file

@ -0,0 +1,48 @@
import * as TE from "fp-ts/TaskEither"
import * as T from "fp-ts/Task"
import { pipe } from "fp-ts/function"
import { AuthType, MediaType, content } from "@hoppscotch/kernel"
import { HoppGQLRequest } from "@hoppscotch/data"
import { transformAuth } from "~/helpers/kernel/common"
import { defaultAuth } from "~/helpers/kernel/common/auth"
import { filterActiveToRecord } from "~/helpers/functional/filter-active"
const parseVariables = async (variables: string | null): Promise<unknown> => {
if (!variables) return undefined
try {
return JSON.parse(variables)
} catch {
throw new Error("Invalid JSON")
}
}
export const GQLRequest = {
async toRequest(request: HoppGQLRequest) {
const headers = {
...filterActiveToRecord(request.headers),
"content-type": "application/json",
}
const auth = await pipe(
transformAuth(request.auth),
TE.getOrElse(() => T.of<AuthType>(defaultAuth))
)()
const variables = await parseVariables(request.variables)
return {
id: Date.now(),
url: request.url,
method: "POST", // GQL specs
version: "HTTP/1.1",
headers,
auth,
content: content.json(
{ query: request.query, variables },
MediaType.APPLICATION_JSON
),
}
},
}

View file

@ -0,0 +1,89 @@
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { RelayResponse } from "@hoppscotch/kernel"
import { RunQueryOptions } from "~/helpers/graphql/connection"
import { OperationType } from "@urql/core"
import { parseBodyAsJSON } from "~/helpers/functional/json"
export type HoppGQLSuccessResponse = {
type: "response"
time: number
operationName: string | undefined
operationType: OperationType
data: string
rawQuery?: RunQueryOptions
}
export type GQLTransformError = {
type: "error"
error: {
type: "transform_error"
message: string
}
}
type GQLParsedResponse = {
data?: unknown
errors?: Array<{ message: string }>
}
const determineOperationType = (query: string): OperationType => {
const trimmed = query.trim().toLowerCase()
if (trimmed.startsWith("mutation")) return "mutation"
if (trimmed.startsWith("subscription")) return "subscription"
return "query"
}
const createTransformError = (message: string): GQLTransformError => ({
type: "error",
error: {
type: "transform_error" as const,
message,
},
})
const validateResponse = (data: unknown): GQLParsedResponse | null => {
if (typeof data !== "object" || data === null) return null
const hasValidData = "data" in data && data.data !== undefined
const hasValidErrors =
"errors" in data &&
Array.isArray(data.errors) &&
data.errors.every(
(e) => typeof e === "object" && e !== null && "message" in e
)
return hasValidData || hasValidErrors ? (data as GQLParsedResponse) : null
}
export const GQLResponse = {
async toResponse(
response: RelayResponse,
options: RunQueryOptions
): Promise<HoppGQLSuccessResponse | GQLTransformError> {
const parsedJSON = pipe(
response.body,
parseBodyAsJSON<unknown>,
O.fold(
() => createTransformError("Invalid JSON response"),
(json) => {
const validBody = validateResponse(json)
return validBody
? {
type: "response" as const,
time: response.meta?.timing
? response.meta.timing.end - response.meta.timing.start
: 0,
operationName: options.operationName,
operationType: determineOperationType(options.query),
data: JSON.stringify(validBody, null, 2),
rawQuery: options,
}
: createTransformError("Invalid GraphQL response structure")
}
)
)
return parsedJSON
},
}

View file

@ -0,0 +1,2 @@
export { RESTRequest } from "./request"
export { RESTResponse } from "./response"

View file

@ -0,0 +1,40 @@
import * as TE from "fp-ts/TaskEither"
import * as T from "fp-ts/Task"
import * as O from "fp-ts/Option"
import { pipe } from "fp-ts/function"
import { Method, RelayRequest, ContentType, AuthType } from "@hoppscotch/kernel"
import { EffectiveHoppRESTRequest } from "~/helpers/utils/EffectiveURL"
import { transformAuth, transformContent } from "~/helpers/kernel/common"
import { defaultAuth } from "~/helpers/kernel/common/auth"
import { filterActiveToRecord } from "~/helpers/functional/filter-active"
export const RESTRequest = {
async toRequest(request: EffectiveHoppRESTRequest): Promise<RelayRequest> {
const auth = await pipe(
transformAuth(request.auth),
TE.getOrElse(() => T.of<AuthType>(defaultAuth))
)()
const content = await pipe(
transformContent(request),
TE.getOrElse(() => T.of<O.Option<ContentType>>(O.none)),
T.map(O.toUndefined)
)()
const headers = filterActiveToRecord(request.effectiveFinalHeaders)
const params = filterActiveToRecord(request.effectiveFinalParams)
return {
id: Date.now(),
url: request.effectiveFinalURL,
method: request.method.toUpperCase() as Method,
version: "HTTP/1.1",
headers,
params,
auth,
content,
}
},
}

View file

@ -0,0 +1,54 @@
import { RelayResponse } from "@hoppscotch/kernel"
import { HoppRESTRequest } from "@hoppscotch/data"
import {
HoppRESTResponseHeader,
HoppRESTSuccessResponse,
} from "~/helpers/types/HoppRESTResponse"
export type HoppRESTTransformError = {
type: "fail"
error: {
type: "transform_error"
message: string
}
}
const extractTiming = (response: RelayResponse): number =>
response.meta?.timing
? response.meta.timing.end - response.meta.timing.start
: 0
const extractSize = (response: RelayResponse): number =>
response.meta?.size?.total ?? 0
export const RESTResponse = {
async toResponse(
response: RelayResponse,
originalRequest: HoppRESTRequest
): Promise<HoppRESTSuccessResponse | HoppRESTTransformError> {
if (!response.body.body || !(response.body.body instanceof Uint8Array)) {
return {
type: "fail",
error: {
type: "transform_error",
message: "Invalid response body format",
},
}
}
return {
type: "success",
headers: Object.entries(response.headers ?? {}).map(
([key, value]) => ({ key, value }) as HoppRESTResponseHeader
),
body: response.body.body.buffer,
statusCode: response.status,
statusText: response.statusText ?? "",
meta: {
responseSize: extractSize(response),
responseDuration: extractTiming(response),
},
req: originalRequest,
}
},
}

View file

@ -1,49 +1,16 @@
import { AxiosRequestConfig } from "axios" import * as TE from "fp-ts/TaskEither"
import { BehaviorSubject, Observable } from "rxjs" import { BehaviorSubject, Observable } from "rxjs"
import { cloneDeep } from "lodash-es" import { cloneDeep } from "lodash-es"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { HoppRESTResponse } from "./types/HoppRESTResponse" import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL" import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL"
import { getService } from "~/modules/dioc" import { getService } from "~/modules/dioc"
import { import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
InterceptorService, import { RESTRequest, RESTResponse } from "~/helpers/kernel/rest"
NetworkResponse, import { RelayError } from "@hoppscotch/kernel"
} from "~/services/interceptor.service"
export type NetworkStrategy = ( export type NetworkStrategy = (
req: AxiosRequestConfig req: EffectiveHoppRESTRequest
) => TE.TaskEither<any, NetworkResponse> ) => TE.TaskEither<RelayError, HoppRESTResponse>
function processResponse(
res: NetworkResponse,
req: EffectiveHoppRESTRequest,
backupTimeStart: number,
backupTimeEnd: number,
successState: HoppRESTResponse["type"]
) {
const contentLength = res.headers["content-length"]
? parseInt(res.headers["content-length"])
: (res.data as ArrayBuffer).byteLength
return <HoppRESTResponse>{
type: successState,
statusCode: res.status,
statusText: res.statusText,
body: res.data,
// If multi headers are present, then we can just use that, else fallback to Axios type
headers:
res.additional?.multiHeaders ??
Object.keys(res.headers).map((x) => ({
key: x,
value: res.headers[x],
})),
meta: {
responseSize: contentLength,
responseDuration: backupTimeEnd - backupTimeStart,
},
req,
}
}
export function createRESTNetworkRequestStream( export function createRESTNetworkRequestStream(
request: EffectiveHoppRESTRequest request: EffectiveHoppRESTRequest
@ -55,52 +22,58 @@ export function createRESTNetworkRequestStream(
const req = cloneDeep(request) const req = cloneDeep(request)
const headers = req.effectiveFinalHeaders.reduce((acc, { key, value }) => { console.info("[helpers/network]: req", req)
return Object.assign(acc, { [key]: value })
}, {})
const params = new URLSearchParams() const execResult = RESTRequest.toRequest(req).then((kernelRequest) => {
for (const param of req.effectiveFinalParams) { console.info("[helpers/network]: kernelRequest", kernelRequest)
params.append(param.key, param.value)
}
const backupTimeStart = Date.now()
const service = getService(InterceptorService)
const res = service.runRequest({
method: req.method as any,
url: req.effectiveFinalURL.trim(),
headers,
params,
data: req.effectiveFinalBody,
})
res.response.then((res) => {
const backupTimeEnd = Date.now()
if (E.isRight(res)) {
const processedRes = processResponse(
res.right,
req,
backupTimeStart,
backupTimeEnd,
"success"
)
response.next(processedRes)
response.complete()
return
}
if (!kernelRequest) {
response.next({ response.next({
type: "network_fail", type: "network_fail",
req, req,
error: res.left, error: new Error("Failed to create kernel request"),
}) })
response.complete() response.complete()
return
}
return service.execute(kernelRequest)
}) })
return [response, () => res.cancel()] const service = getService(KernelInterceptorService)
execResult.then((result) => {
if (!result) return
result.response.then(async (res) => {
if (res._tag === "Right") {
const processedRes = await RESTResponse.toResponse(res.right, req)
if (processedRes.type === "success") {
response.next(processedRes)
} else {
response.next({
type: "network_fail",
req,
error: processedRes.error,
})
}
} else {
response.next({
type: "interceptor_error",
req,
error: res.left,
})
}
response.complete()
})
})
return [
response,
async () => {
const result = await execResult
if (result) await result.cancel()
},
]
} }

View file

@ -1,129 +1,17 @@
import { getService } from "~/modules/dioc"
import { PersistenceService } from "~/services/persistence"
import * as E from "fp-ts/Either" import * as E from "fp-ts/Either"
import { z } from "zod" import { z } from "zod"
import { InterceptorService } from "~/services/interceptor.service"
import { AxiosRequestConfig } from "axios" import { getService } from "~/modules/dioc"
import { PersistenceService } from "~/services/persistence"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { content } from "@hoppscotch/kernel"
const kernelInterceptor = getService(KernelInterceptorService)
const persistenceService = getService(PersistenceService)
const redirectUri = `${window.location.origin}/oauth` const redirectUri = `${window.location.origin}/oauth`
const interceptorService = getService(InterceptorService) export type TokenRequestParams = {
const persistenceService = getService(PersistenceService)
// GENERAL HELPER FUNCTIONS
/**
* Parse a query string into an object
*
* @param {String} searchQuery - The search query params
* @returns {Object}
*/
const parseQueryString = (searchQuery: string): Record<string, string> => {
if (searchQuery === "") {
return {}
}
const segments = searchQuery.split("&").map((s) => s.split("="))
const queryString = segments.reduce(
(obj, el) => ({ ...obj, [el[0]]: el[1] }),
{}
)
return queryString
}
/**
* Get OAuth configuration from OpenID Discovery endpoint
*
* @returns {Object}
*/
const getTokenConfiguration = async (endpoint: string) => {
const options = {
method: "GET",
headers: {
"Content-type": "application/json",
},
}
try {
const res = await runRequestThroughInterceptor({
url: endpoint,
...options,
})
if (E.isLeft(res)) {
return E.left("OIDC_DISCOVERY_FAILED")
}
return E.right(JSON.parse(res.right))
} catch (e) {
return E.left("OIDC_DISCOVERY_FAILED")
}
}
// PKCE HELPER FUNCTIONS
/**
* Generates a secure random string using the browser crypto functions
*
* @returns {Object}
*/
const generateRandomString = () => {
const array = new Uint32Array(28)
window.crypto.getRandomValues(array)
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
}
/**
* Calculate the SHA256 hash of the input text
*
* @returns {Promise<ArrayBuffer>}
*/
const sha256 = (plain: string) => {
const encoder = new TextEncoder()
const data = encoder.encode(plain)
return window.crypto.subtle.digest("SHA-256", data)
}
/**
* Encodes the input string into Base64 format
*
* @param {String} str - The string to be converted
* @returns {Promise<ArrayBuffer>}
*/
const base64urlencode = (
str: ArrayBuffer // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
) => {
const hashArray = Array.from(new Uint8Array(str))
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
// Then convert the base64 encoded to base64url encoded
// (replace + with -, replace / with _, trim trailing =)
return btoa(String.fromCharCode.apply(null, hashArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
/**
* Return the base64-urlencoded sha256 hash for the PKCE challenge
*
* @param {String} v - The randomly generated string
* @returns {String}
*/
const pkceChallengeFromVerifier = async (v: string) => {
const hashed = await sha256(v)
return base64urlencode(hashed)
}
// OAUTH REQUEST
type TokenRequestParams = {
oidcDiscoveryUrl: string oidcDiscoveryUrl: string
grantType: string grantType: string
authUrl: string authUrl: string
@ -133,14 +21,48 @@ type TokenRequestParams = {
scope: string scope: string
} }
/** async function getTokenConfiguration(endpoint: string) {
* Initiates PKCE Auth Code flow when requested const { response } = kernelInterceptor.execute({
* id: Date.now(),
* @param {Object} - The necessary params url: endpoint,
* @returns {Void} method: "GET",
*/ version: "HTTP/1.1",
headers: {
"Content-Type": ["application/json"],
},
})
const tokenRequest = async ({ const result = await response
if (E.isLeft(result)) return E.left("OIDC_DISCOVERY_FAILED")
const jsonContent = result.right.content
if (jsonContent.kind !== "json") return E.left("OIDC_DISCOVERY_FAILED")
return E.right(jsonContent.content)
}
const generateRandomString = () => {
const array = new Uint32Array(28)
window.crypto.getRandomValues(array)
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
}
const base64urlencode = (str: ArrayBuffer) => {
const hashArray = Array.from(new Uint8Array(str))
return btoa(String.fromCharCode.apply(null, hashArray))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "")
}
const pkceChallengeFromVerifier = async (v: string) => {
const encoder = new TextEncoder()
const data = encoder.encode(v)
const hashed = await window.crypto.subtle.digest("SHA-256", data)
return base64urlencode(hashed)
}
export const tokenRequest = async ({
oidcDiscoveryUrl, oidcDiscoveryUrl,
grantType, grantType,
authUrl, authUrl,
@ -157,123 +79,91 @@ const tokenRequest = async ({
token_endpoint: z.string(), token_endpoint: z.string(),
}) })
if (E.isLeft(res)) { if (E.isLeft(res)) return E.left("OIDC_DISCOVERY_FAILED")
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right) const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
if (!parsedOIDCConfiguration.success) return E.left("OIDC_DISCOVERY_FAILED")
if (!parsedOIDCConfiguration.success) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
authUrl = parsedOIDCConfiguration.data.authorization_endpoint authUrl = parsedOIDCConfiguration.data.authorization_endpoint
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
} }
// Store oauth information
persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
persistenceService.setLocalConfig("client_id", clientId)
persistenceService.setLocalConfig("client_secret", clientSecret)
// Create and store a random state value await persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
await persistenceService.setLocalConfig("client_id", clientId)
await persistenceService.setLocalConfig("client_secret", clientSecret)
const state = generateRandomString() const state = generateRandomString()
persistenceService.setLocalConfig("pkce_state", state) await persistenceService.setLocalConfig("pkce_state", state)
// Create and store a new PKCE codeVerifier (the plaintext random secret)
const codeVerifier = generateRandomString() const codeVerifier = generateRandomString()
persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier) await persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier)
// Hash and base64-urlencode the secret to use as the challenge
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier) const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
// Build the authorization URL const url = new URL(authUrl)
const buildUrl = () => url.searchParams.set("response_type", grantType)
`${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent( url.searchParams.set("client_id", clientId)
clientId url.searchParams.set("state", state)
)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent( url.searchParams.set("scope", scope)
scope url.searchParams.set("redirect_uri", redirectUri)
)}&redirect_uri=${encodeURIComponent( url.searchParams.set("code_challenge", codeChallenge)
redirectUri url.searchParams.set("code_challenge_method", "S256")
)}&code_challenge=${encodeURIComponent(
codeChallenge
)}&code_challenge_method=S256`
// Redirect to the authorization server window.location.assign(url.toString())
window.location.assign(buildUrl())
} }
// OAUTH REDIRECT HANDLING export const handleOAuthRedirect = async () => {
const queryParams = Object.fromEntries(
new URLSearchParams(window.location.search)
)
/** if (queryParams.error) return E.left("AUTH_SERVER_RETURNED_ERROR")
* Handle the redirect back from the authorization server and if (!queryParams.code) return E.left("NO_AUTH_CODE")
* get an access token from the token endpoint if (
* (await persistenceService.getLocalConfig("pkce_state")) !==
* @returns {Promise<any | void>} queryParams.state
*/ ) {
return E.left("INVALID_STATE")
const handleOAuthRedirect = async () => {
const queryParams = parseQueryString(window.location.search.substring(1))
// Check if the server returned an error string
if (queryParams.error) {
return E.left("AUTH_SERVER_RETURNED_ERROR" as const)
} }
if (!queryParams.code) { const tokenEndpoint = await persistenceService.getLocalConfig("tokenEndpoint")
return E.left("NO_AUTH_CODE" as const) const clientID = await persistenceService.getLocalConfig("client_id")
} const clientSecret = await persistenceService.getLocalConfig("client_secret")
const codeVerifier =
await persistenceService.getLocalConfig("pkce_codeVerifier")
// If the server returned an authorization code, attempt to exchange it for an access token if (!tokenEndpoint) return E.left("NO_TOKEN_ENDPOINT")
// Verify state matches what we set at the beginning if (!clientID) return E.left("NO_CLIENT_ID")
if (persistenceService.getLocalConfig("pkce_state") !== queryParams.state) { if (!clientSecret) return E.left("NO_CLIENT_SECRET")
return E.left("INVALID_STATE" as const) if (!codeVerifier) return E.left("NO_CODE_VERIFIER")
}
const tokenEndpoint = persistenceService.getLocalConfig("tokenEndpoint") const requestParams = {
const clientID = persistenceService.getLocalConfig("client_id")
const clientSecret = persistenceService.getLocalConfig("client_secret")
const codeVerifier = persistenceService.getLocalConfig("pkce_codeVerifier")
if (!tokenEndpoint) {
return E.left("NO_TOKEN_ENDPOINT" as const)
}
if (!clientID) {
return E.left("NO_CLIENT_ID" as const)
}
if (!clientSecret) {
return E.left("NO_CLIENT_SECRET" as const)
}
if (!codeVerifier) {
return E.left("NO_CODE_VERIFIER" as const)
}
const data = new URLSearchParams({
grant_type: "authorization_code", grant_type: "authorization_code",
code: queryParams.code, code: queryParams.code,
client_id: clientID, client_id: clientID,
client_secret: clientSecret, client_secret: clientSecret,
redirect_uri: redirectUri, redirect_uri: redirectUri,
code_verifier: codeVerifier, code_verifier: codeVerifier,
}) }
// Exchange the authorization code for an access token const { response } = kernelInterceptor.execute({
const tokenResponse = await runRequestThroughInterceptor({ id: Date.now(),
url: tokenEndpoint, url: tokenEndpoint,
data: data.toString(),
method: "POST", method: "POST",
version: "HTTP/1.1",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": ["application/x-www-form-urlencoded"],
}, },
content: content.urlencoded(requestParams),
}) })
// Clean these up since we don't need them anymore
clearPKCEState() clearPKCEState()
if (E.isLeft(tokenResponse)) { const result = await response
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const) if (E.isLeft(result)) return E.left("AUTH_TOKEN_REQUEST_FAILED")
if (result.right.content.kind !== "json") {
return E.left("AUTH_TOKEN_REQUEST_FAILED")
} }
const withAccessTokenSchema = z.object({ const withAccessTokenSchema = z.object({
@ -281,36 +171,18 @@ const handleOAuthRedirect = async () => {
}) })
const parsedTokenResponse = withAccessTokenSchema.safeParse( const parsedTokenResponse = withAccessTokenSchema.safeParse(
JSON.parse(tokenResponse.right) result.right.content.content
) )
return parsedTokenResponse.success return parsedTokenResponse.success
? E.right(parsedTokenResponse.data) ? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const) : E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
} }
const clearPKCEState = () => { const clearPKCEState = async () => {
persistenceService.removeLocalConfig("pkce_state") await persistenceService.removeLocalConfig("pkce_state")
persistenceService.removeLocalConfig("pkce_codeVerifier") await persistenceService.removeLocalConfig("pkce_codeVerifier")
persistenceService.removeLocalConfig("tokenEndpoint") await persistenceService.removeLocalConfig("tokenEndpoint")
persistenceService.removeLocalConfig("client_id") await persistenceService.removeLocalConfig("client_id")
persistenceService.removeLocalConfig("client_secret") await persistenceService.removeLocalConfig("client_secret")
} }
async function runRequestThroughInterceptor(config: AxiosRequestConfig) {
const res = await interceptorService.runRequest(config).response
if (E.isLeft(res)) {
return E.left("REQUEST_FAILED")
}
// convert ArrayBuffer to string
if (!(res.right.data instanceof ArrayBuffer)) {
return E.left("REQUEST_FAILED")
}
const data = new TextDecoder().decode(res.right.data).replace(/\0+$/, "")
return E.right(data)
}
export { tokenRequest, handleOAuthRedirect }

View file

@ -211,7 +211,12 @@ export class TeamSearchService extends Service {
this.searchResultsRequests = {} this.searchResultsRequests = {}
this.expandedCollections.value = [] this.expandedCollections.value = []
const axiosPlatformConfig = platform.auth.axiosPlatformConfig?.() ?? {} const getAxiosPlatformConfig = async () => {
await platform.auth.waitProbableLoginToConfirm()
return platform.auth.axiosPlatformConfig?.() ?? {}
}
const axiosPlatformConfig = await getAxiosPlatformConfig()
try { try {
const searchResponse = await axios.get( const searchResponse = await axios.get(

View file

@ -1,34 +1,10 @@
import { HoppRESTRequest } from "@hoppscotch/data" import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue" import { Component } from "vue"
import { KernelInterceptorError } from "~/services/kernel-interceptor.service"
export type HoppRESTResponseHeader = { key: string; value: string } export type HoppRESTResponseHeader = { key: string; value: string }
export type HoppRESTResponse = export type HoppRESTSuccessResponse = {
| { type: "loading"; req: HoppRESTRequest }
| {
type: "fail"
headers: HoppRESTResponseHeader[]
body: ArrayBuffer
statusCode: number
statusText: string
meta: {
responseSize: number // in bytes
responseDuration: number // in millis
}
req: HoppRESTRequest
}
| {
type: "network_fail"
error: unknown
req: HoppRESTRequest
}
| {
type: "script_fail"
error: Error
}
| {
type: "success" type: "success"
headers: HoppRESTResponseHeader[] headers: HoppRESTResponseHeader[]
body: ArrayBuffer body: ArrayBuffer
@ -38,12 +14,56 @@ export type HoppRESTResponse =
responseSize: number // in bytes responseSize: number // in bytes
responseDuration: number // in millis responseDuration: number // in millis
} }
req: HoppRESTRequest req: HoppRESTRequest
}
export type HoppRESTFailureResponse = {
type: "failure"
headers: HoppRESTResponseHeader[]
body: ArrayBuffer
statusCode: number
statusText: string
meta: {
responseSize: number // in bytes
responseDuration: number // in millis
} }
| { req: HoppRESTRequest
}
export type HoppRESTFailureNetwork = {
type: "network_fail"
error: unknown
req: HoppRESTRequest
}
export type HoppRESTFailureScript = {
type: "script_fail"
error: Error
}
export type HoppRESTErrorExtension = {
type: "extension_error" type: "extension_error"
error: string error: string
component: Component component: Component
req: HoppRESTRequest req: HoppRESTRequest
} }
export type HoppRESTErrorInterceptor = {
type: "interceptor_error"
error: KernelInterceptorError
req: HoppRESTRequest
}
export type HoppRESTLoadingResponse = {
type: "loading"
req: HoppRESTRequest
}
export type HoppRESTResponse =
| HoppRESTLoadingResponse
| HoppRESTSuccessResponse
| HoppRESTFailureResponse
| HoppRESTFailureNetwork
| HoppRESTFailureScript
| HoppRESTFailureExtension
| HoppRESTFailureInterceptor

Some files were not shown because too many files have changed in this diff Show more