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
**/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
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
# 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-gen/
# Devenv
.devenv*
devenv.local.nix
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml

View file

@ -21,6 +21,11 @@
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
handle {
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 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 webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server")
caddyProcess.on("exit", (code) => {
console.log(`Exiting process because Caddy Server exited with code ${code}`)
@ -63,11 +64,17 @@ backendProcess.on("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', () => {
console.log("SIGINT received, exiting...")
caddyProcess.kill("SIGINT")
backendProcess.kill("SIGINT")
webappProcess.kill("SIGINT")
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.
# 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:
# This service runs the backend app in the port 3170
hoppscotch-backend:
profiles: ["backend", "just-backend"]
container_name: hoppscotch-backend
build:
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
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
hoppscotch-app:
profiles: ["app"]
container_name: hoppscotch-app
build:
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
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
hoppscotch-sh-admin:
profiles: ["admin"]
container_name: hoppscotch-sh-admin
build:
dockerfile: prod.Dockerfile
@ -62,8 +91,22 @@ services:
- "3280:80"
- "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:
profiles: ["default", "default-no-db"]
container_name: hoppscotch-aio
restart: unless-stopped
build:
@ -79,12 +122,14 @@ services:
- "3000:3000"
- "3100:3100"
- "3170:3170"
- "3200:3200"
- "3080:80"
# The preset DB service, you can delete/comment the below lines if
# you are using an external postgres instance
# This will be exposed at port 5432
hoppscotch-db:
profiles: ["default", "database", "just-backend"]
image: postgres:15
ports:
- "5432:5432"
@ -105,8 +150,24 @@ services:
timeout: 5s
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:
profiles: ["deprecated"]
container_name: hoppscotch-old-backend
build:
dockerfile: packages/hoppscotch-backend/Dockerfile
@ -130,6 +191,7 @@ services:
- "3170:3000"
hoppscotch-old-app:
profiles: ["deprecated"]
container_name: hoppscotch-old-app
build:
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
@ -142,6 +204,7 @@ services:
- "3000:8080"
hoppscotch-old-sh-admin:
profiles: ["deprecated"]
container_name: hoppscotch-old-sh-admin
build:
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
@ -152,3 +215,29 @@ services:
- hoppscotch-old-backend
ports:
- "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,
"declarationDir": "./dist",
"moduleResolution": "node",
"skipLibCheck": true,
"allowJs": true
},
"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",
"axios": "^1.7.7",
"fp-ts": "^2.16.9",
"lodash-es": "4.17.21",
"vue": "3.3.9"
},
"devDependencies": {
@ -29,6 +30,7 @@
"typescript": "^5.6.3",
"unplugin-icons": "^0.19.3",
"vite": "^5.4.8",
"@types/lodash-es": "4.17.12",
"vue-tsc": "^2.1.6"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
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."
authors = ["AndrewBastin", "CuriousCorrelation"]
edition = "2021"
@ -29,9 +29,10 @@ tokio-util = "0.7.12"
uuid = { version = "1.11.0", features = [ "v4", "fast-rng" ] }
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8.5"
log = "0.4.22"
env_logger = "0.11.5"
hoppscotch-relay = { path = "../../hoppscotch-relay" }
tracing = "0.1.40"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "fmt", "std", "time"] }
tracing-appender = "0.2.3"
relay = { git = "https://github.com/CuriousCorrelation/relay.git" }
thiserror = "1.0.64"
tauri-plugin-store = "2.1.0"
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-http = { version = "2.0.1", features = ["gzip"] }
native-dialog = "0.7.0"
sha2 = "0.10.8"
[target.'cfg(windows)'.dependencies]
tempfile = { version = "3.13.0" }
winreg = { version = "0.52.0" }
[dev-dependencies]
mockito = "1.5.0"
[features]
default = ["tauri-plugin-autostart"]
portable = []

View file

@ -18,6 +18,7 @@
"core:default",
"shell:allow-open",
"core:window:allow-close",
"core:window:allow-hide",
"core:window:allow-set-focus",
"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::{
body::Bytes,
extract::{Path, State},
@ -8,46 +10,57 @@ use axum_extra::{
headers::{authorization::Bearer, Authorization},
TypedHeader,
};
use hoppscotch_relay::{RequestWithMetadata, ResponseWithMetadata};
use std::sync::Arc;
use chrono::Utc;
use rand::Rng;
use serde_json::json;
use tauri::{AppHandle, Emitter};
use uuid::Uuid;
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::{
error::{AgentError, AgentResult},
model::{AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse},
state::{AppState, Registration},
util::EncryptedJson,
global::NONCE,
model::{
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 {
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
format!("{:06}", otp)
let formatted = format!("{:06}", otp);
tracing::debug!("Generated OTP: {}", formatted);
formatted
}
#[tracing::instrument(skip(app_handle))]
pub async fn handshake(
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<HandshakeResponse>> {
Ok(Json(HandshakeResponse {
tracing::info!("Processing handshake request");
let response = HandshakeResponse {
status: "success".to_string(),
__hoppscotch__agent__: true,
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(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
) -> AgentResult<Json<serde_json::Value>> {
let otp = generate_otp();
tracing::info!("Generated new registration OTP");
let mut active_registration_code = state.active_registration_code.write().await;
if !active_registration_code.is_none() {
tracing::warn!("Registration attempt while another registration is active");
return Ok(Json(
json!({ "message": "There is already an existing registration happening" }),
));
@ -55,32 +68,73 @@ pub async fn receive_registration(
*active_registration_code = Some(otp.clone());
app_handle
.emit("registration_received", otp)
.map_err(|_| AgentError::InternalServerError)?;
match app_handle.emit("registration-received", otp) {
Ok(_) => {
tracing::info!("Registration event emitted successfully");
Ok(Json(
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(
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
) -> AgentResult<Json<AuthKeyResponse>> {
state
tracing::info!("Verifying registration request");
if !state
.validate_registration(&confirmed_registration.registration)
.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 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 agent_public_key = PublicKey::from(&agent_secret_key);
let auth_key_copy = auth_key.clone();
let secret_key = EphemeralSecret::random();
let public_key = PublicKey::from(&secret_key);
let their_public_key = {
let public_key_slice: &[u8; 32] =
@ -92,9 +146,9 @@ pub async fn verify_registration(
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(
auth_key_copy,
Registration {
@ -102,82 +156,102 @@ pub async fn verify_registration(
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()),
},
);
})?;
}) {
tracing::error!("Failed to update registrations: {:?}", e);
return Err(e);
}
let auth_payload = json!({
"auth_key": auth_key,
"created_at": created_at
});
app_handle
.emit("authenticated", &auth_payload)
.map_err(|_| AgentError::InternalServerError)?;
if let Err(e) = app_handle.emit("authenticated", &auth_payload) {
tracing::error!("Failed to emit authenticated event: {:?}", e);
return Err(AgentError::InternalServerError);
}
let _ = state.clear_active_registration().await;
tracing::info!("Registration verified successfully");
Ok(Json(AuthKeyResponse {
auth_key,
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>(
State((state, _app_handle)): State<(Arc<AppState>, T)>,
#[tracing::instrument(skip(state, app_handle), fields(auth_key = %auth_key))]
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>>,
headers: HeaderMap,
body: Bytes,
) -> AgentResult<EncryptedJson<ResponseWithMetadata>> {
let nonce = headers
.get("X-Hopp-Nonce")
.ok_or(AgentError::Unauthorized)?
.to_str()
.map_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),
) -> AgentResult<EncryptedJson<relay::Response>> {
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);
}
},
_ = cancel_token.cancelled() => {
Err(AgentError::RequestCancelled)
None => {
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,
data: val,
})
data: response,
})?)
}
/// 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
/// 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.
#[tracing::instrument(skip(state, _app_handle))]
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>>,
) -> 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 {
Some(reg) => Ok(EncryptedJson {
Some(reg) => {
tracing::info!("Handshake successful");
Ok(EncryptedJson {
key_b16: reg.shared_secret_b16,
data: json!(true),
}),
None => Err(AgentError::Unauthorized),
})
}
None => {
tracing::warn!("Unauthorized handshake attempt");
Err(AgentError::Unauthorized)
}
}
}
pub async fn cancel_request<T>(
State((state, _app_handle)): State<(Arc<AppState>, T)>,
#[tracing::instrument(skip(state, _app_handle), fields(request_id = %request_id))]
pub async fn cancel(
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Path(req_id): Path<usize>,
Path(request_id): Path<usize>,
) -> AgentResult<Json<serde_json::Value>> {
if !state.validate_access(auth_header.token()) {
tracing::warn!("Unauthorized cancellation attempt");
return Err(AgentError::Unauthorized);
}
if let Some((_, token)) = state.remove_cancellation_token(req_id) {
token.cancel();
if let Ok(()) = relay::cancel(request_id.try_into().unwrap()).await {
tracing::info!("Request cancelled successfully");
Ok(Json(json!({"message": "Request cancelled successfully"})))
} else {
tracing::warn!("Request not found");
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()
.unwrap_or_default();
log::error!("{}: {}", FATAL_ERROR, msg);
tracing::error!("{}: {}", FATAL_ERROR, msg);
panic!("{}: {}", FATAL_ERROR, msg);
}
pub fn info(msg: &str) {
log::info!("{}", msg);
tracing::info!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Info)
@ -27,7 +27,7 @@ pub fn info(msg: &str) {
}
pub fn warn(msg: &str) {
log::warn!("{}", msg);
tracing::warn!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Warning)
@ -38,7 +38,7 @@ pub fn warn(msg: &str) {
}
pub fn error(msg: &str) {
log::error!("{}", msg);
tracing::error!("{}", msg);
MessageDialog::new()
.set_type(MessageType::Error)

View file

@ -8,6 +8,10 @@ use thiserror::Error;
#[derive(Error, Debug)]
pub enum AgentError {
#[error("FATAL: No `main` window found")]
NoMainWindow,
#[error("Tauri error: {0}")]
Tauri(#[from] tauri::Error),
#[error("Invalid Registration")]
InvalidRegistration,
#[error("Invalid Client Public Key")]
@ -45,7 +49,13 @@ pub enum AgentError {
#[error("Store error: {0}")]
TauriPluginStore(#[from] tauri_plugin_store::Error),
#[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 {
@ -55,7 +65,9 @@ impl IntoResponse for AgentError {
AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, 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::ClientCertError => (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 REGISTRATIONS: &str = "registrations";
pub const NONCE: &str = "X-Hopp-Nonce";

View file

@ -1,3 +1,4 @@
pub mod command;
pub mod controller;
pub mod dialog;
pub mod error;
@ -11,59 +12,105 @@ pub mod updater;
pub mod util;
pub mod webview;
use log::{error, info};
use std::sync::Arc;
use tauri::{Emitter, Listener, Manager, WebviewWindowBuilder};
use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindowBuilder};
use tauri_plugin_updater::UpdaterExt;
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;
#[tauri::command]
async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
Ok(state.active_registration_code.read().await.clone())
#[tracing::instrument(skip(app_handle))]
fn create_main_window(app_handle: &AppHandle) -> AgentResult<()> {
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)]
pub fn run() {
env_logger::init();
tracing::info!("Initializing Hoppscotch Agent");
// The installer takes care of installing `WebView`,
// this check is only required for portable variant.
#[cfg(all(feature = "portable", windows))]
{
tracing::debug!("Checking WebView initialization for portable Windows variant");
webview::init_webview();
}
let cancellation_token = CancellationToken::new();
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,
// so `tauri_plugin_single_instance` needs to be registered first.
// See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance
.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))
.unwrap();
if let Err(e) = app.emit("single-instance", Payload::new(args, cwd)) {
tracing::error!(error = %e, "Failed to emit single-instance event");
}
// Application is already running, bring it to foreground.
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
} else {
error!("Failed to get `main` window");
if let Err(e) = show_main_window(&app) {
tracing::error!(error = %e, "Failed to show window");
}
}))
.plugin(tauri_plugin_store::Builder::new().build())
.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")))]
{
use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_autostart::ManagerExt;
tracing::debug!("Configuring autostart for desktop variant");
let _ = app.handle().plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
@ -71,30 +118,29 @@ pub fn run() {
let autostart_manager = app.autolaunch();
println!(
"autostart enabled: {}",
autostart_manager.is_enabled().unwrap()
tracing::info!(
enabled = autostart_manager.is_enabled().unwrap_or(false),
"Checking autostart status"
);
if !autostart_manager.is_enabled().unwrap() {
let _ = autostart_manager.enable();
println!(
"autostart updated: {}",
autostart_manager.is_enabled().unwrap()
);
if !autostart_manager.is_enabled().unwrap_or(false) {
if let Err(e) = autostart_manager.enable() {
tracing::error!(error = %e, "Failed to enable autostart");
} else {
tracing::info!("Autostart enabled successfully");
}
}
};
#[cfg(desktop)]
{
tracing::debug!("Initializing desktop-specific features");
let _ = app
.handle()
.plugin(tauri_plugin_updater::Builder::new().build());
let _ = app.handle().plugin(tauri_plugin_dialog::init());
let updater = app.updater_builder().build().unwrap();
let app_handle_ref = app_handle.clone();
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());
let server_cancellation_token = server_cancellation_token.clone();
let server_app_handle = app_handle.clone();
tracing::debug!("Spawning server process");
tauri::async_runtime::spawn(async move {
server::run_server(app_state, server_cancellation_token, server_app_handle).await;
});
#[cfg(all(desktop))]
{
tracing::debug!("Creating system tray");
let handle = app.handle();
tray::create_tray(handle)?;
}
@ -125,50 +175,68 @@ pub fn run() {
// Blocks the app from populating the macOS dock
#[cfg(target_os = "macos")]
{
tracing::debug!("Setting macOS activation policy");
app_handle
.set_activation_policy(tauri::ActivationPolicy::Accessory)
.unwrap();
};
let app_handle_ref = app_handle.clone();
app_handle.listen("registration_received", move |_| {
WebviewWindowBuilder::from_config(
&app_handle_ref,
&app_handle_ref.config().app.windows[0],
)
.unwrap()
.build()
.unwrap()
.show()
.unwrap();
app_handle.listen("registration-received", move |_| {
tracing::info!("Registration received event triggered");
if let Err(e) = show_main_window(&app_handle_ref) {
tracing::error!(error = %e, "Failed to show window");
}
});
tracing::info!("Application setup completed successfully");
Ok(())
})
.manage(cancellation_token)
.on_window_event(|window, 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 mut current_code = app_state.active_registration_code.blocking_write();
if current_code.is_some() {
tracing::debug!("Clearing active registration code");
*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!())
.expect("error while building tauri application")
.run(|app_handle, event| match event {
.expect("error while building tauri application");
tracing::info!("Running application");
app.run(|app_handle, event| match event {
tauri::RunEvent::ExitRequested { api, code, .. } => {
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() {
tracing::info!("Exit with non-zero code requested, initiating shutdown");
let state = app_handle.state::<CancellationToken>();
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!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
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()
}

View file

@ -1,5 +1,42 @@
use chrono::{DateTime, Utc};
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.
#[derive(Clone, Serialize)]
@ -45,3 +82,29 @@ pub struct AuthKeyResponse {
/// and client after registration
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::{
routing::{get, post},
routing::{delete, get, post},
Router,
};
use std::sync::Arc;
@ -22,7 +22,13 @@ pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
"/registered-handshake",
get(controller::registered_handshake),
)
.route("/request", post(controller::run_request))
.route("/cancel-request/:req_id", post(controller::cancel_request))
.route("/registration", get(controller::registration))
.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))
}

View file

@ -6,11 +6,13 @@ use tower_http::cors::CorsLayer;
use crate::route;
use crate::state::AppState;
#[tracing::instrument(skip(state, cancellation_token, app_handle))]
pub async fn run_server(
state: Arc<AppState>,
cancellation_token: CancellationToken,
app_handle: tauri::AppHandle,
) {
tracing::info!("Initializing server");
let cors = CorsLayer::permissive();
let app = Router::new()
@ -18,17 +20,31 @@ pub async fn run_server(
.layer(cors);
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();
axum::serve(listener, app.into_make_service())
if let Err(e) = axum::serve(listener, app.into_make_service())
.with_graceful_shutdown(async move {
cancellation_token.cancelled().await;
tracing::info!("Graceful shutdown initiated");
})
.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 axum::body::Bytes;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde::de::DeserializeOwned;
use tauri_plugin_store::StoreExt;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken;
@ -10,20 +9,10 @@ use tokio_util::sync::CancellationToken;
use crate::{
error::{AgentError, AgentResult},
global::{AGENT_STORE, REGISTRATIONS},
model::Registration,
};
/// 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(Default)]
#[derive(Debug, Default)]
pub struct AppState {
/// The active registration code that is being registered.
pub active_registration_code: RwLock<Option<String>>,
@ -37,19 +26,36 @@ pub struct AppState {
}
impl AppState {
#[tracing::instrument(skip(app_handle))]
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,
// load the default list
let registrations = store
.get(REGISTRATIONS)
.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
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 {
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
/// reference, you shouldn't do it for registrations as `update_registrations`
/// performs save operation that needs to be done and should be used instead
#[tracing::instrument]
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
tracing::debug!("Retrieving registrations list");
&self.registrations
}
@ -71,60 +79,117 @@ impl AppState {
/// This function bypasses `store.reload()` to avoid issues from stale or inconsistent
/// 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.
#[tracing::instrument(skip(self, app_handle, update_func))]
pub fn update_registrations(
&self,
app_handle: tauri::AppHandle,
update_func: impl FnOnce(&DashMap<String, Registration>),
) -> Result<(), AgentError> {
tracing::info!("Updating 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) {
tracing::debug!("Clearing existing registrations from store");
// We've confirmed `REGISTRATIONS` exists in the store
store
.delete(REGISTRATIONS)
.then_some(())
.ok_or(AgentError::RegistrationClearError)?;
if !store.delete(REGISTRATIONS) {
tracing::error!("Failed to clear existing registrations");
return Err(AgentError::RegistrationClearError);
}
} 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,
// we avoid reloading the store from disk and instead choose to override it.
store.set(
REGISTRATIONS,
serde_json::to_value(self.registrations.clone())?,
);
match serde_json::to_value(self.registrations.clone()) {
Ok(value) => {
let _ = store.set(REGISTRATIONS, value);
}
Err(e) => {
tracing::error!("Failed to serialize registrations: {}", e);
return Err(e.into());
}
}
// 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(())
}
/// Clear all the registrations
#[tracing::instrument(skip(self, app_handle))]
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 {
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)> {
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) {
self.cancellation_tokens.insert(req_id, cancellation_tokens);
#[tracing::instrument(skip(self))]
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 {
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>(
&self,
auth_key: &str,
@ -134,28 +199,79 @@ impl AppState {
where
T: DeserializeOwned,
{
if let Some(registration) = self.registrations.get(auth_key) {
let key: [u8; 32] = base16::decode(&registration.shared_secret_b16).ok()?[0..32]
.try_into()
.ok()?;
tracing::debug!(
auth_key,
nonce_len = nonce.len(),
"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 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()
} else {
match serde_json::from_reader(plain_data.as_slice()) {
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
}
}
}
pub fn get_registration_info(&self, auth_key: &str) -> Option<Registration> {
self.registrations
#[tracing::instrument(skip(self))]
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)
.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 std::sync::Arc;
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Manager,
AppHandle, Emitter, Manager,
};
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,
None::<&str>,
)?;
let show_registrations = MenuItem::with_id(
app,
"show_registrations",
"Show Registrations",
true,
None::<&str>,
)?;
let pkg_info = app.package_info();
let app_name = pkg_info.name.clone();
@ -42,6 +49,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.item(&app_version_item)
.separator()
.item(&clear_registrations)
.item(&show_registrations)
.separator()
.item(&quit_i)
.build()?;
@ -57,8 +66,9 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.menu_on_left_click(true)
.on_menu_event(move |app, event| match event.id.as_ref() {
"quit" => {
log::info!("Exiting the agent...");
app.exit(-1);
tracing::info!("Exiting the agent...");
// Exit with a specific code to allow actual exit.
app.exit(1);
}
"clear_registrations" => {
let app_state = app.state::<Arc<AppState>>();
@ -67,8 +77,16 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
.clear_registrations(app.clone())
.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| {
@ -79,9 +97,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
} = event
{
let app = tray.app_handle();
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
if let Err(e) = show_main_window(&app) {
tracing::error!("Failed to show window from tray: {}", e);
}
}
})

View file

@ -7,6 +7,14 @@ use axum::{
};
use rand::rngs::OsRng;
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<()> {
let null = Stdio::null();
@ -79,7 +87,7 @@ where
let response_headers = response.headers_mut();
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
}

View file

@ -182,7 +182,7 @@ pub fn init_webview() {
)
.not()
{
log::warn!("Declined to setup WebView.");
tracing::warn!("Declined to setup WebView.");
std::process::exit(1);
}
@ -196,7 +196,7 @@ pub fn init_webview() {
));
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);

View file

@ -1,71 +1,179 @@
<template>
<div class="font-sans min-h-screen flex flex-col">
<div class="p-5 flex flex-col flex-grow gap-y-2">
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
<div class="h-screen p-5 flex flex-col gap-y-2">
<h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1>
<template v-if="isOtpView(state())">
<div v-if="state().otp" class="flex-grow">
<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 close 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.
</p>
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">
{{ otpCode }}
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"
>{{ pipe(state().otp, O.getOrElse(() => "")) }}</p>
</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">
<HoppButtonSecondary
v-if="shouldShowCopy(state())"
label="Copy Code"
outline
filled
:icon="copyIcon"
@click="copyCode"
/>
<HoppButtonPrimary
label="Close"
outline
@click="closeWindow"
@click="copyOtp"
/>
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" />
</div>
</div>
</template>
<script setup>
<script setup lang="ts">
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 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'
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()
const otpCode = ref("")
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
function copyCode() {
copyIcon.value = markRaw(IconCheck)
copy(otpCode.value)
interface Registration {
auth_key_hash: string
registered_at: string
}
function closeWindow() {
const currentWindow = getCurrentWindow()
currentWindow.close()
interface AppState {
view: "otp" | "registrations"
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 () => {
const currentWindow = getCurrentWindow()
getCurrentWindow().setAlwaysOnTop(true)
currentWindow.setFocus(true);
currentWindow.setAlwaysOnTop(true);
otpCode.value = await invoke("get_otp", {})
await listen('registration_received', (event) => {
otpCode.value = event.payload
await pipe(
getOtp,
TE.map((otp: string) => {
if (otp) appState.value = { ...state(), otp: O.some(otp) }
})
)()
await listen('authenticated', () => {
closeWindow()
})
await Promise.all([
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>

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 { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
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 { UserRequestModule } from './user-request/user-request.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 { TeamEnvironmentsModule } from './team-environments/team-environments.module';
import { TeamCollectionModule } from './team-collection/team-collection.module';
@ -52,20 +52,29 @@ import { InfraTokenModule } from './infra-token/infra-token.module';
subscriptions: {
'subscriptions-transport-ws': {
path: '/graphql',
onConnect: (_, websocket) => {
onConnect: (connectionParams, websocket) => {
const websocketHeaders = websocket?.upgradeReq?.headers;
try {
const cookies = subscriptionContextCookieParser(
websocket.upgradeReq.headers.cookie,
);
return {
headers: { ...websocket?.upgradeReq?.headers, cookies },
};
} catch (error) {
const accessToken = extractAccessTokenFromAuthRecords(connectionParams);
const authorization = `Bearer ${accessToken}`
return { headers: { ...websocketHeaders, authorization } };
} catch (authError) {
const cookiesFromHeader = websocketHeaders?.cookie;
const cookies = cookiesFromHeader
? subscriptionContextCookieParser(cookiesFromHeader)
: null;
if (!cookies) {
throw new HttpException(COOKIES_NOT_FOUND, 400, {
cause: new Error(COOKIES_NOT_FOUND),
});
}
},
return { headers: { ...websocketHeaders, cookies } };
}
}
},
},
context: ({ req, res, connection }) => ({

View file

@ -193,4 +193,24 @@ export class AuthController {
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
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 { Response } from 'express';
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 { ConfigService } from '@nestjs/config';
import { IncomingHttpHeaders } from 'http';
enum AuthTokenType {
ACCESS_TOKEN = 'access_token',
@ -125,3 +126,94 @@ export function authProviderCheck(
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 { PassportStrategy } from '@nestjs/passport';
import {
Injectable,
ForbiddenException,
UnauthorizedException,
} from '@nestjs/common';
import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { AccessTokenPayload } from 'src/types/AuthTokens';
import { UserService } from 'src/user/user.service';
import { AuthService } from '../auth.service';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import * as O from 'fp-ts/Option';
import {
COOKIES_NOT_FOUND,
INVALID_ACCESS_TOKEN,
USER_NOT_FOUND,
} from 'src/errors';
import { ConfigService } from '@nestjs/config';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/function';
import { COOKIES_NOT_FOUND, INVALID_ACCESS_TOKEN, USER_NOT_FOUND } from 'src/errors';
/**
* 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()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@ -25,13 +70,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
) {
super({
jwtFromRequest: ExtractJwt.fromExtractors([
(request: Request) => {
const ATCookie = request.cookies['access_token'];
if (!ATCookie) {
throw new ForbiddenException(COOKIES_NOT_FOUND);
}
return ATCookie;
},
(request: Request) =>
pipe(
extractToken(request),
E.fold(
error => { throw error; },
token => { return token }
)
),
]),
secretOrKey: configService.get('JWT_SECRET'),
});

View file

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

View file

@ -9,8 +9,11 @@
"select_workspace": "Select a workspace",
"clear": "Clear",
"clear_all": "Clear all",
"clear_cache": "Clear Cache",
"clear_history": "Clear all History",
"clear_unpinned": "Clear Unpinned",
"close": "Close",
"confirm": "Confirm",
"connect": "Connect",
"connecting": "Connecting",
"copy": "Copy",
@ -18,6 +21,7 @@
"delete": "Delete",
"disconnect": "Disconnect",
"dismiss": "Dismiss",
"done": "Done",
"dont_save": "Don't save",
"download_file": "Download file",
"download_test_report": "Download test report",
@ -42,6 +46,7 @@
"properties": "Properties",
"register": "Register",
"remove": "Remove",
"remove_instance": "Remove instance",
"rename": "Rename",
"restore": "Restore",
"retry": "Retry",
@ -96,7 +101,6 @@
"enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration",
"otp_label": "Verification Code",
"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",
"registration_title": "Agent registration",
"verify_ssl_certs": "Verify SSL Certificates",
@ -255,8 +259,7 @@
"nonce_count": "Nonce Count",
"client_nonce": "Client Nonce",
"opaque": "Opaque",
"disable_retry": "Disable Retrying Request",
"inspector_warning": "Agent interceptor is recommended when using Digest Authorization."
"disable_retry": "Disable Retrying Request"
}
},
"collection": {
@ -416,6 +419,43 @@
"details": "Details"
},
"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",
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
"check_console_details": "Check console log for details.",
@ -536,7 +576,8 @@
"collection": "Collapse Collection Panel",
"more": "Hide more",
"preview": "Hide Preview",
"sidebar": "Collapse sidebar"
"sidebar": "Collapse sidebar",
"password": "Hide Password"
},
"import": {
"collections": "Import collections",
@ -591,6 +632,17 @@
"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."
},
"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": {
"description": "Inspect possible errors",
"environment": {
@ -614,10 +666,36 @@
"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.",
"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": {
"active_interceptor_doesnt_support_binary_body": "Sending binary data via the current interceptor is not supported yet."
"auth": {
"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": {
@ -797,6 +875,13 @@
"account_email_description": "Your primary email address.",
"account_name_description": "This is your display name.",
"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_tooltip": "Encode the parameters in the request only if some special characters are present",
"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.",
"disable_encode_mode_tooltip": "Never 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",
"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, ",
@ -819,6 +905,8 @@
"general_description": " General settings used in the application",
"interceptor": "Interceptor",
"interceptor_description": "Middleware between application and APIs.",
"kernel_interceptor": "Interceptor",
"kernel_interceptor_description": "Middleware between application and APIs.",
"language": "Language",
"light_mode": "Light",
"official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.",
@ -858,7 +946,33 @@
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
"user": "User",
"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": {
"button": "Button",
@ -967,7 +1081,8 @@
"code": "Show code",
"collection": "Expand Collection Panel",
"more": "Show more",
"sidebar": "Expand sidebar"
"sidebar": "Expand sidebar",
"password": "Show Password"
},
"socketio": {
"communication": "Communication",

View file

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

View file

@ -24,6 +24,7 @@ declare module 'vue' {
AppHeader: typeof import('./components/app/Header.vue')['default']
AppInspection: typeof import('./components/app/Inspection.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']
AppMarkdown: typeof import('./components/app/Markdown.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']
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
InstanceSwitcher: typeof import('./components/instance/Switcher.vue')['default']
InterceptorsAgentModalNativeCACertificates: typeof import('./components/interceptors/agent/ModalNativeCACertificates.vue')['default']
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
@ -255,7 +257,10 @@ declare module 'vue' {
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
RealtimeSubscription: typeof import('./components/realtime/Subscription.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']
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
SettingsNative: typeof import('./components/settings/Native.vue')['default']
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
Share: typeof import('./components/share/index.vue')['default']
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']

View file

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

View file

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

View file

@ -2,10 +2,11 @@
<div>
<header
ref="headerRef"
data-tauri-drag-region
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
data-tauri-drag-region
class="col-span-2 flex items-center justify-between space-x-2"
:style="{
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
@ -13,17 +14,50 @@
}"
>
<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
v-else
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
:label="t('app.name')"
to="/"
/>
</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 />
</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">
<HoppButtonSecondary
v-if="showInstallButton"
@ -177,9 +211,8 @@
</span>
<span
class="inline-flex truncate text-secondaryLight text-tiny"
>{{ currentUser.email }}</span
>
{{ currentUser.email }}
</span>
</div>
<hr />
<HoppSmartItem
@ -241,6 +274,8 @@
</template>
<script setup lang="ts">
import { getKernelMode } from "@hoppscotch/kernel"
import { useI18n } from "@composables/i18n"
import { useReadonlyStream } from "@composables/stream"
import { defineActionHandler, invokeAction } from "@helpers/actions"
@ -260,6 +295,7 @@ import {
BannerService,
} from "~/services/banner.service"
import { WorkspaceService } from "~/services/workspace.service"
import { InstanceSwitcherService } from "~/services/instance-switcher.service"
import IconDownload from "~icons/lucide/download"
import IconLifeBuoy from "~icons/lucide/life-buoy"
import IconSettings from "~icons/lucide/settings"
@ -267,9 +303,33 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
import IconUser from "~icons/lucide/user"
import IconUserPlus from "~icons/lucide/user-plus"
import IconUsers from "~icons/lucide/users"
import IconChevronDown from "~icons/lucide/chevron-down"
const t = useI18n()
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

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 { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
import { useService } from "dioc/vue"
import { computed, ref, useSlots } from "vue"
import { computed, onMounted, ref, useSlots } from "vue"
import { PersistenceService } from "~/services/persistence"
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
@ -104,23 +104,26 @@ if (!COLUMN_LAYOUT.value) {
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
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
const verticalPaneData = getPaneData("vertical")
const verticalPaneData = await getPaneData("vertical")
if (verticalPaneData) {
const [mainPane, sidebarPane] = verticalPaneData
PANE_MAIN_SIZE.value = mainPane?.size
PANE_SIDEBAR_SIZE.value = sidebarPane?.size
}
const horizontalPaneData = getPaneData("horizontal")
const horizontalPaneData = await getPaneData("horizontal")
if (horizontalPaneData) {
const [mainTopPane, mainBottomPane] = horizontalPaneData
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 paneEvent = persistenceService.getLocalConfig(storageKey)
const paneEvent = await persistenceService.getLocalConfig(storageKey)
if (!paneEvent) return null
return JSON.parse(paneEvent)
}
populatePaneEvent()
onMounted(async () => {
await populatePaneEvent()
})
</script>

View file

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

View file

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

View file

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

View file

@ -199,7 +199,7 @@ const activeTabIsDetails = computed(() => activeTab.value === "details")
watch(
editableCollection,
(updatedEditableCollection) => {
async (updatedEditableCollection) => {
if (props.show && props.editingProperties) {
const unsavedCollectionProperties: EditingProperties = {
collection: updatedEditableCollection,
@ -207,7 +207,7 @@ watch(
path: props.editingProperties.path,
inheritedProperties: props.editingProperties.inheritedProperties,
}
persistenceService.setLocalConfig(
await persistenceService.setLocalConfig(
"unsaved_collection_properties",
JSON.stringify(unsavedCollectionProperties)
)
@ -222,7 +222,7 @@ const activeTab = useVModel(props, "modelValue", emit)
watch(
() => props.show,
(show) => {
async (show) => {
// `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
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
const finalCollection = clone(editableCollection.value)
const collection = {
@ -262,11 +264,11 @@ const saveEditedCollection = () => {
isRootCollection: props.editingProperties.isRootCollection,
}
emit("set-collection-properties", collection as EditingProperties)
persistenceService.removeLocalConfig("unsaved_collection_properties")
await persistenceService.removeLocalConfig("unsaved_collection_properties")
}
const hideModal = () => {
persistenceService.removeLocalConfig("unsaved_collection_properties")
const hideModal = async () => {
await persistenceService.removeLocalConfig("unsaved_collection_properties")
emit("hide-modal")
}

View file

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

View file

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

View file

@ -440,9 +440,9 @@ const persistenceService = useService(PersistenceService)
const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers")
onMounted(() => {
onMounted(async () => {
const localOAuthTempConfig =
persistenceService.getLocalConfig("oauth_temp_config")
await persistenceService.getLocalConfig("oauth_temp_config")
if (!localOAuthTempConfig) {
return
@ -457,9 +457,8 @@ onMounted(() => {
if (context?.type === "collection-properties") {
// load the unsaved editing properties
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig(
"unsaved_collection_properties"
)
const unsavedCollectionPropertiesString =
await persistenceService.getLocalConfig("unsaved_collection_properties")
if (unsavedCollectionPropertiesString) {
const unsavedCollectionProperties: EditingProperties = JSON.parse(
@ -481,7 +480,7 @@ onMounted(() => {
editingProperties.value = unsavedCollectionProperties
}
persistenceService.removeLocalConfig("oauth_temp_config")
await persistenceService.removeLocalConfig("oauth_temp_config")
collectionPropertiesModalActiveTab.value = "authorization"
showModalEditProperties.value = true
}
@ -2642,7 +2641,7 @@ const initializeDownloadCollection = async (
collectionJSON: string,
name: string | null
) => {
const result = await platform.io.saveFileWithDialog({
const result = await platform.kernelIO.saveFileWithDialog({
data: collectionJSON,
contentType: "application/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 { cloneDeep } from "lodash-es"
import { ref, watch, computed } from "vue"
import { InterceptorService } from "~/services/interceptor.service"
import { EditCookieConfig } from "./EditCookie.vue"
import { useColorMode } from "@composables/theming"
import { useToast } from "@composables/toast"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
const props = defineProps<{
show: boolean
@ -168,17 +168,16 @@ const toast = useToast()
const newDomainText = ref("")
const interceptorService = useService(InterceptorService)
const interceptorService = useService(KernelInterceptorService)
const cookieJarService = useService(CookieJarService)
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
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 currentInterceptor.supportsCookies ?? false
return supportsCookies ?? false
})
function addNewDomain() {

View file

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

View file

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

View file

@ -68,7 +68,7 @@ import { computed, ref, watch } from "vue"
import { connection } from "~/helpers/graphql/connection"
import { connect } 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 { defineActionHandler } from "~/helpers/actions"
import { GQLTabService } from "~/services/tab/graphql"
@ -77,7 +77,7 @@ import { HoppGQLAuth, HoppGQLRequest } from "@hoppscotch/data"
const t = useI18n()
const tabs = useService(GQLTabService)
const interceptorService = useService(InterceptorService)
const interceptorService = useService(KernelInterceptorService)
const connectionSwitchModal = ref(false)
@ -120,7 +120,7 @@ const gqlConnect = () => {
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
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 { editGraphqlRequest } from "~/newstore/collections"
import { platform } from "~/platform"
import { InterceptorService } from "~/services/interceptor.service"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { GQLTabService } from "~/services/tab/graphql"
const VALID_GQL_OPERATIONS = [
@ -86,7 +86,7 @@ const VALID_GQL_OPERATIONS = [
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
const interceptorService = useService(InterceptorService)
const interceptorService = useService(KernelInterceptorService)
const t = useI18n()
const toast = useToast()
@ -174,7 +174,7 @@ const runQuery = async (
platform.analytics?.logEvent({
type: "HOPP_REQUEST_RUN",
platform: "graphql-query",
strategy: interceptorService.currentInterceptorID.value!,
strategy: interceptorService.current.value!.id,
})
}

View file

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

View file

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

View file

@ -29,6 +29,26 @@
v-if="response.type === 'extension_error'"
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
v-if="response.type === 'network_fail'"
:src="`/images/states/${colorMode.value}/upload_error.svg`"

View file

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

View file

@ -45,11 +45,13 @@
import { computed, ref, watch } from "vue"
import { useI18n } from "@composables/i18n"
import { useToast } from "~/composables/toast"
import { InterceptorService } from "~/services/interceptor.service"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { useService } from "dioc/vue"
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()
@ -83,34 +85,27 @@ const disableImportCTA = computed(() => !hasURL.value || props.loading)
const urlFetchLogic =
props.fetchLogic ??
async function (url: string) {
const res = await interceptorService.runRequest({
const { response } = interceptorService.execute({
id: Date.now(),
url: url,
transitional: {
forcedJSONParsing: false,
silentJSONParsing: false,
clarifyTimeoutError: true,
},
method: "GET",
version: "HTTP/1.1",
})
const response = await res.response
const res = await response
if (E.isLeft(response)) {
if (E.isLeft(res)) {
return E.left("REQUEST_FAILED")
}
// convert ArrayBuffer to string
if (!(response.right.data instanceof ArrayBuffer)) {
return E.left("REQUEST_FAILED")
const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
if (O.isSome(responsePayload)) {
return E.right(responsePayload)
}
try {
return E.right(
InterceptorService.convertArrayBufferToString(response.right.data)
)
} catch (e) {
return E.left("REQUEST_FAILED")
}
}
async function fetchUrlData() {
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 =
persistenceService.getLocalConfig("lens_html_preview") === "true"
(await persistenceService.getLocalConfig("lens_html_preview")) === "true"
const { previewFrame, previewEnabled, togglePreview } = usePreview(
defaultPreview,
responseBodyText
)
const doTogglePreview = () => {
persistenceService.setLocalConfig(
const doTogglePreview = async () => {
await persistenceService.setLocalConfig(
"lens_html_preview",
previewEnabled.value ? "false" : "true"
)

View file

@ -1,106 +1,661 @@
<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">
<HoppSmartToggle
:on="allowSSLVerification"
@change="allowSSLVerification = !allowSSLVerification"
:on="domainSettings[selectedDomain]?.security?.verifyHost"
@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 class="flex space-x-4">
<HoppButtonSecondary
:icon="IconLucideFileBadge"
:label="'CA Certificates'"
:icon="IconFileBadge"
:label="t('settings.ca_certificates')"
outline
@click="showCACertificatesModal = true"
@click="showCACertModal = true"
/>
<HoppButtonSecondary
:icon="IconLucideFileKey"
:label="t('agent.client_certs')"
:icon="IconFileKey"
:label="t('settings.client_certificates')"
outline
@click="showClientCertificatesModal = true"
@click="showClientCertModal = true"
/>
</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">
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
{{ t("agent.use_http_proxy") }}
<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-if="allowProxy"
v-model="proxyURL"
:autofocus="false"
styles="flex-1"
placeholder=" "
:label="t('settings.proxy_url')"
input-styles="input floating-input"
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>
<p class="my-1 text-secondaryLight">
{{ t("agent.proxy_capabilities") }}
<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 { computed, ref } from "vue"
import { useI18n } from "@composables/i18n"
import IconLucideFileKey from "~icons/lucide/file-key"
import IconLucideFileBadge from "~icons/lucide/file-badge"
import { ref, reactive, computed, onMounted } from "vue"
import { useService } from "dioc/vue"
import {
RequestDef,
AgentInterceptorService,
} from "~/platform/std/interceptors/agent"
import { syncRef } from "@vueuse/core"
import { useI18n } from "@composables/i18n"
import { useToast } from "@composables/toast"
import { useCertificatePicker } from "@composables/picker"
import { KernelInterceptorAgentStore } from "~/platform/std/kernel-interceptors/agent/store"
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 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 showClientCertificatesModal = ref(false)
const showClientCertModal = 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)
const proxyURL = ref("")
return client.kind === "pem"
? !client.cert || !client.key
: !client.data || !client.password
})
const proxyInfo = computed<RequestProxyInfo>({
get() {
if (allowProxy.value) {
return {
url: proxyURL.value,
}
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()),
}
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 {
allowProxy.value = false
},
},
})
},
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],
},
},
})
},
})
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>

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") }}
</span>
</div>
<div class="flex flex-col space-y-2 py-4">
<span>
<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"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
:info-icon="hasChromeExtInstalled ? IconCheckCircle : undefined"
:active-info-icon="hasChromeExtInstalled"
outline
/>
</span>
<span>
<HoppSmartItem
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
blank
:icon="IconFirefox"
label="Firefox"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : undefined"
:active-info-icon="hasFirefoxExtInstalled"
outline
/>
</span>
</div>
</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 class="flex items-center space-x-2 py-4">
<HoppSmartInput
v-model="PROXY_URL"
v-model="proxyUrl"
:autofocus="false"
styles="flex-1"
placeholder=" "
:placeholder="' '"
:label="t('settings.proxy_url')"
input-styles="input floating-input"
:disabled="!proxyEnabled"
:disabled="!enabled"
@change="updateProxyUrl"
/>
<HoppButtonSecondary
v-tippy="{ theme: 'tooltip' }"
@ -24,50 +25,61 @@
:icon="clearIcon"
outline
class="rounded"
@click="resetProxy"
@click="resetSettings"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue"
import { refAutoReset } from "@vueuse/core"
import { useService } from "dioc/vue"
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 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 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(
platform.auth.getCurrentUserStream(),
platform.auth.getCurrentUser()
)
async function updateProxyUrl() {
await store.updateSettings({ proxyUrl: proxyUrl.value })
toast.success(t("state.saved"))
}
watch(
() => currentUser.value,
async () => {
if (!currentUser.value) {
PROXY_URL.value = await getDefaultProxyUrl()
proxyUrl.value = await getDefaultProxyUrl()
}
}
)
const proxyEnabled = computed(
() =>
interceptorService.currentInterceptorID.value ===
proxyInterceptor.interceptorID
const enabled = computed(
() => interceptorService.getCurrentId() === proxyInterceptorService.id
)
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
@ -75,9 +87,16 @@ const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
1000
)
const resetProxy = async () => {
PROXY_URL.value = await getDefaultProxyUrl()
async function resetSettings() {
await store.resetSettings()
const settings = store.getSettings()
proxyUrl.value = settings.proxyUrl
clearIcon.value = IconCheck
toast.success(`${t("state.cleared")}`)
toast.success(t("state.cleared"))
}
onMounted(async () => {
const settings = store.getSettings()
proxyUrl.value = settings.proxyUrl
})
</script>

View file

@ -68,7 +68,7 @@ export function useDownloadResponse(
const dataToWrite = responseBody.value
// TODO: Look at the mime type and determine extension ?
const result = await platform.io.saveFileWithDialog({
const result = await platform.kernelIO.saveFileWithDialog({
data: dataToWrite,
contentType: contentType,
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 { version as hoppscotchCommonPkgVersion } from "./../../package.json"
export function useWhatsNewDialog() {
export async function useWhatsNewDialog() {
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
if (!versionFromLocalStorage) {
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
await persistenceService.setLocalConfig(
"hopp_v",
hoppscotchCommonPkgVersion
)
return
}
@ -53,7 +57,7 @@ export function useWhatsNewDialog() {
}, 10000)
}
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
await persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
}
async function getReleaseNotes(v: string): Promise<string | undefined> {

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ export const initializeDownloadFile = async (
const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0]
const result = await platform.io.saveFileWithDialog({
const result = await platform.kernelIO.saveFileWithDialog({
data: contentsJSON,
contentType: "application/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 result = await platform.io.saveFileWithDialog({
const result = await platform.kernelIO.saveFileWithDialog({
data: contentsJSON,
contentType: "application/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 * as E from "fp-ts/Either"
import * as O from "fp-ts/Option"
import { z } from "zod"
import { v4 as uuidv4 } from "uuid"
import { Ref } from "vue"
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: {
caption: string
@ -48,31 +50,27 @@ export function GistSource(metadata: {
}
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()}`,
method: "GET",
version: "HTTP/1.1",
headers: {
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")
}
// convert ArrayBuffer to string
if (!(response.right.data instanceof ArrayBuffer)) {
return E.left("REQUEST_FAILED")
const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
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")
}
}

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 { cloneDeep } from "lodash-es"
import * as E from "fp-ts/Either"
import * as TE from "fp-ts/TaskEither"
import { HoppRESTResponse } from "./types/HoppRESTResponse"
import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL"
import { getService } from "~/modules/dioc"
import {
InterceptorService,
NetworkResponse,
} from "~/services/interceptor.service"
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
import { RESTRequest, RESTResponse } from "~/helpers/kernel/rest"
import { RelayError } from "@hoppscotch/kernel"
export type NetworkStrategy = (
req: AxiosRequestConfig
) => TE.TaskEither<any, NetworkResponse>
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,
}
}
req: EffectiveHoppRESTRequest
) => TE.TaskEither<RelayError, HoppRESTResponse>
export function createRESTNetworkRequestStream(
request: EffectiveHoppRESTRequest
@ -55,52 +22,58 @@ export function createRESTNetworkRequestStream(
const req = cloneDeep(request)
const headers = req.effectiveFinalHeaders.reduce((acc, { key, value }) => {
return Object.assign(acc, { [key]: value })
}, {})
console.info("[helpers/network]: req", req)
const params = new URLSearchParams()
for (const param of req.effectiveFinalParams) {
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
}
const execResult = RESTRequest.toRequest(req).then((kernelRequest) => {
console.info("[helpers/network]: kernelRequest", kernelRequest)
if (!kernelRequest) {
response.next({
type: "network_fail",
req,
error: res.left,
error: new Error("Failed to create kernel request"),
})
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 { 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 interceptorService = getService(InterceptorService)
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 = {
export type TokenRequestParams = {
oidcDiscoveryUrl: string
grantType: string
authUrl: string
@ -133,14 +21,48 @@ type TokenRequestParams = {
scope: string
}
/**
* Initiates PKCE Auth Code flow when requested
*
* @param {Object} - The necessary params
* @returns {Void}
*/
async function getTokenConfiguration(endpoint: string) {
const { response } = kernelInterceptor.execute({
id: Date.now(),
url: endpoint,
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,
grantType,
authUrl,
@ -157,123 +79,91 @@ const tokenRequest = async ({
token_endpoint: z.string(),
})
if (E.isLeft(res)) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
if (E.isLeft(res)) return E.left("OIDC_DISCOVERY_FAILED")
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
if (!parsedOIDCConfiguration.success) {
return E.left("OIDC_DISCOVERY_FAILED" as const)
}
if (!parsedOIDCConfiguration.success) return E.left("OIDC_DISCOVERY_FAILED")
authUrl = parsedOIDCConfiguration.data.authorization_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()
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()
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)
// Build the authorization URL
const buildUrl = () =>
`${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent(
clientId
)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(
scope
)}&redirect_uri=${encodeURIComponent(
redirectUri
)}&code_challenge=${encodeURIComponent(
codeChallenge
)}&code_challenge_method=S256`
const url = new URL(authUrl)
url.searchParams.set("response_type", grantType)
url.searchParams.set("client_id", clientId)
url.searchParams.set("state", state)
url.searchParams.set("scope", scope)
url.searchParams.set("redirect_uri", redirectUri)
url.searchParams.set("code_challenge", codeChallenge)
url.searchParams.set("code_challenge_method", "S256")
// Redirect to the authorization server
window.location.assign(buildUrl())
window.location.assign(url.toString())
}
// OAUTH REDIRECT HANDLING
export const handleOAuthRedirect = async () => {
const queryParams = Object.fromEntries(
new URLSearchParams(window.location.search)
)
/**
* Handle the redirect back from the authorization server and
* get an access token from the token endpoint
*
* @returns {Promise<any | void>}
*/
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.error) return E.left("AUTH_SERVER_RETURNED_ERROR")
if (!queryParams.code) return E.left("NO_AUTH_CODE")
if (
(await persistenceService.getLocalConfig("pkce_state")) !==
queryParams.state
) {
return E.left("INVALID_STATE")
}
if (!queryParams.code) {
return E.left("NO_AUTH_CODE" as const)
}
const tokenEndpoint = await persistenceService.getLocalConfig("tokenEndpoint")
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
// Verify state matches what we set at the beginning
if (persistenceService.getLocalConfig("pkce_state") !== queryParams.state) {
return E.left("INVALID_STATE" as const)
}
if (!tokenEndpoint) return E.left("NO_TOKEN_ENDPOINT")
if (!clientID) return E.left("NO_CLIENT_ID")
if (!clientSecret) return E.left("NO_CLIENT_SECRET")
if (!codeVerifier) return E.left("NO_CODE_VERIFIER")
const tokenEndpoint = persistenceService.getLocalConfig("tokenEndpoint")
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({
const requestParams = {
grant_type: "authorization_code",
code: queryParams.code,
client_id: clientID,
client_secret: clientSecret,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
})
}
// Exchange the authorization code for an access token
const tokenResponse = await runRequestThroughInterceptor({
const { response } = kernelInterceptor.execute({
id: Date.now(),
url: tokenEndpoint,
data: data.toString(),
method: "POST",
version: "HTTP/1.1",
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()
if (E.isLeft(tokenResponse)) {
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
const result = await response
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({
@ -281,36 +171,18 @@ const handleOAuthRedirect = async () => {
})
const parsedTokenResponse = withAccessTokenSchema.safeParse(
JSON.parse(tokenResponse.right)
result.right.content.content
)
return parsedTokenResponse.success
? E.right(parsedTokenResponse.data)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
}
const clearPKCEState = () => {
persistenceService.removeLocalConfig("pkce_state")
persistenceService.removeLocalConfig("pkce_codeVerifier")
persistenceService.removeLocalConfig("tokenEndpoint")
persistenceService.removeLocalConfig("client_id")
persistenceService.removeLocalConfig("client_secret")
const clearPKCEState = async () => {
await persistenceService.removeLocalConfig("pkce_state")
await persistenceService.removeLocalConfig("pkce_codeVerifier")
await persistenceService.removeLocalConfig("tokenEndpoint")
await persistenceService.removeLocalConfig("client_id")
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.expandedCollections.value = []
const axiosPlatformConfig = platform.auth.axiosPlatformConfig?.() ?? {}
const getAxiosPlatformConfig = async () => {
await platform.auth.waitProbableLoginToConfirm()
return platform.auth.axiosPlatformConfig?.() ?? {}
}
const axiosPlatformConfig = await getAxiosPlatformConfig()
try {
const searchResponse = await axios.get(

View file

@ -1,34 +1,10 @@
import { HoppRESTRequest } from "@hoppscotch/data"
import { Component } from "vue"
import { KernelInterceptorError } from "~/services/kernel-interceptor.service"
export type HoppRESTResponseHeader = { key: string; value: string }
export type HoppRESTResponse =
| { 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
}
| {
export type HoppRESTSuccessResponse = {
type: "success"
headers: HoppRESTResponseHeader[]
body: ArrayBuffer
@ -38,12 +14,56 @@ export type HoppRESTResponse =
responseSize: number // in bytes
responseDuration: number // in millis
}
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"
error: string
component: Component
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