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:
parent
3563e1eb16
commit
a6147f4ce4
465 changed files with 54061 additions and 4097 deletions
|
|
@ -1,2 +1,35 @@
|
||||||
|
.devenv*
|
||||||
|
.direnv
|
||||||
|
.devcontainer
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
.husky
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
.envrc
|
||||||
|
devenv.yaml
|
||||||
|
devenv.nix
|
||||||
|
.prettierrc.js
|
||||||
|
.prettierignore
|
||||||
|
.editorconfig
|
||||||
|
.npmrc
|
||||||
|
.firebaserc
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
**/node_modules
|
||||||
**/*/node_modules
|
**/*/node_modules
|
||||||
|
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/target
|
||||||
|
|
||||||
|
**/__tests__
|
||||||
|
**/*.test.*
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
*.md
|
||||||
|
LICENSE
|
||||||
|
CODEOWNERS
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,14 @@ DATA_ENCRYPTION_KEY="data encryption key with 32 char"
|
||||||
|
|
||||||
# Hoppscotch App Domain Config
|
# Hoppscotch App Domain Config
|
||||||
REDIRECT_URL="http://localhost:3000"
|
REDIRECT_URL="http://localhost:3000"
|
||||||
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100"
|
# Whitelisted origins for the Hoppscotch App.
|
||||||
|
# This list controls which origins can interact with the app through cross-origin comms.
|
||||||
|
# - localhost ports (3170, 3000, 3100): app, backend, development servers and services
|
||||||
|
# - app://localhost_3200: Bundle server origin identifier
|
||||||
|
# NOTE: `3200` here refers to the bundle server (port 3200) that provides the bundles,
|
||||||
|
# NOT where the app runs. The app itself uses the `app://` protocol with dynamic
|
||||||
|
# bundle names like `app://{bundle-name}/`
|
||||||
|
WHITELISTED_ORIGINS="http://localhost:3170,http://localhost:3000,http://localhost:3100,app://localhost_3200,app://hoppscotch"
|
||||||
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
|
VITE_ALLOWED_AUTH_PROVIDERS=GOOGLE,GITHUB,MICROSOFT,EMAIL
|
||||||
|
|
||||||
# Google Auth Config
|
# Google Auth Config
|
||||||
|
|
|
||||||
3
.envrc
Normal file
3
.envrc
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
source_url "https://raw.githubusercontent.com/cachix/devenv/82c0147677e510b247d8b9165c54f73d32dfd899/direnvrc" "sha256-7u4iDd1nZpxL4tCzmPG0dQgC5V+/44Ba+tHkPob1v2k="
|
||||||
|
|
||||||
|
use devenv
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -172,3 +172,13 @@ tests/*/videos
|
||||||
|
|
||||||
# GQL SDL generated for the frontends
|
# GQL SDL generated for the frontends
|
||||||
gql-gen/
|
gql-gen/
|
||||||
|
|
||||||
|
# Devenv
|
||||||
|
.devenv*
|
||||||
|
devenv.local.nix
|
||||||
|
|
||||||
|
# direnv
|
||||||
|
.direnv
|
||||||
|
|
||||||
|
# pre-commit
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@
|
||||||
reverse_proxy localhost:8080
|
reverse_proxy localhost:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Handle requests under `/desktop-app-server*` path
|
||||||
|
handle_path /desktop-app-server* {
|
||||||
|
reverse_proxy localhost:3200
|
||||||
|
}
|
||||||
|
|
||||||
# Catch-all route for unknown paths, serves `selfhost-web` SPA
|
# Catch-all route for unknown paths, serves `selfhost-web` SPA
|
||||||
handle {
|
handle {
|
||||||
root * /site/selfhost-web
|
root * /site/selfhost-web
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ fs.rmSync("build.env")
|
||||||
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
|
const caddyFileName = process.env.ENABLE_SUBPATH_BASED_ACCESS === 'true' ? 'aio-subpath-access.Caddyfile' : 'aio-multiport-setup.Caddyfile'
|
||||||
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
const caddyProcess = runChildProcessWithPrefix("caddy", ["run", "--config", `/etc/caddy/${caddyFileName}`, "--adapter", "caddyfile"], "App/Admin Dashboard Caddy")
|
||||||
const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/main.js"], "Backend Server")
|
const backendProcess = runChildProcessWithPrefix("node", ["/dist/backend/dist/main.js"], "Backend Server")
|
||||||
|
const webappProcess = runChildProcessWithPrefix("webapp-server", [], "Webapp Server")
|
||||||
|
|
||||||
caddyProcess.on("exit", (code) => {
|
caddyProcess.on("exit", (code) => {
|
||||||
console.log(`Exiting process because Caddy Server exited with code ${code}`)
|
console.log(`Exiting process because Caddy Server exited with code ${code}`)
|
||||||
|
|
@ -63,11 +64,17 @@ backendProcess.on("exit", (code) => {
|
||||||
process.exit(code)
|
process.exit(code)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
webappProcess.on("exit", (code) => {
|
||||||
|
console.log(`Exiting process because Webapp Server exited with code ${code}`)
|
||||||
|
process.exit(code)
|
||||||
|
})
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
console.log("SIGINT received, exiting...")
|
console.log("SIGINT received, exiting...")
|
||||||
|
|
||||||
caddyProcess.kill("SIGINT")
|
caddyProcess.kill("SIGINT")
|
||||||
backendProcess.kill("SIGINT")
|
backendProcess.kill("SIGINT")
|
||||||
|
webappProcess.kill("SIGINT")
|
||||||
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
140
devenv.lock
Normal file
140
devenv.lock
Normal 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
189
devenv.nix
Normal 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"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2,9 +2,36 @@
|
||||||
# has a container with a Postgres instance running.
|
# has a container with a Postgres instance running.
|
||||||
# You can tweak around this file to match your instances
|
# You can tweak around this file to match your instances
|
||||||
|
|
||||||
|
# PROFILES EXPLANATION:
|
||||||
|
#
|
||||||
|
# We use Docker Compose profiles to manage different deployment scenarios and avoid port conflicts.
|
||||||
|
#
|
||||||
|
# These are all the available profiles:
|
||||||
|
# - default: All-in-one service + database + auto-migration (recommended for most users)
|
||||||
|
# - default-no-db: All-in-one service without database (for users with external DB)
|
||||||
|
# - backend: The backend service only
|
||||||
|
# - app: The main Hoppscotch application only
|
||||||
|
# - admin: The self-host admin dashboard only
|
||||||
|
# - webapp: The static web app server only
|
||||||
|
# - database: Just the PostgreSQL database
|
||||||
|
# - just-backend: All services except webapp for local development
|
||||||
|
# - deprecated: All deprecated services (not recommended)
|
||||||
|
|
||||||
|
# USAGE:
|
||||||
|
#
|
||||||
|
# To run the default setup: docker compose --profile default up
|
||||||
|
# To run without database: docker compose --profile default-no-db up
|
||||||
|
# To run specific components: docker compose --profile backend up
|
||||||
|
# To run all except webapp: docker compose --profile just-backend up
|
||||||
|
# To run deprecated services: docker compose --profile deprecated up
|
||||||
|
|
||||||
|
# NOTE: The default and default-no-db profiles should not be mixed with individual service
|
||||||
|
# profiles as they would conflict on ports.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# This service runs the backend app in the port 3170
|
# This service runs the backend app in the port 3170
|
||||||
hoppscotch-backend:
|
hoppscotch-backend:
|
||||||
|
profiles: ["backend", "just-backend"]
|
||||||
container_name: hoppscotch-backend
|
container_name: hoppscotch-backend
|
||||||
build:
|
build:
|
||||||
dockerfile: prod.Dockerfile
|
dockerfile: prod.Dockerfile
|
||||||
|
|
@ -32,6 +59,7 @@ services:
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||||
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
# the SH admin dashboard server at packages/hoppscotch-selfhost-web/Caddyfile
|
||||||
hoppscotch-app:
|
hoppscotch-app:
|
||||||
|
profiles: ["app"]
|
||||||
container_name: hoppscotch-app
|
container_name: hoppscotch-app
|
||||||
build:
|
build:
|
||||||
dockerfile: prod.Dockerfile
|
dockerfile: prod.Dockerfile
|
||||||
|
|
@ -49,6 +77,7 @@ services:
|
||||||
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
# NOTE: To do TLS or play around with how the app is hosted, you can look into the Caddyfile for
|
||||||
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
# the SH admin dashboard server at packages/hoppscotch-sh-admin/Caddyfile
|
||||||
hoppscotch-sh-admin:
|
hoppscotch-sh-admin:
|
||||||
|
profiles: ["admin"]
|
||||||
container_name: hoppscotch-sh-admin
|
container_name: hoppscotch-sh-admin
|
||||||
build:
|
build:
|
||||||
dockerfile: prod.Dockerfile
|
dockerfile: prod.Dockerfile
|
||||||
|
|
@ -62,8 +91,22 @@ services:
|
||||||
- "3280:80"
|
- "3280:80"
|
||||||
- "3100:3100"
|
- "3100:3100"
|
||||||
|
|
||||||
# The service that spins up all 3 services at once in one container
|
# The static server for serving web content to desktop shell, hosted at port 3200
|
||||||
|
hoppscotch-webapp-server:
|
||||||
|
profiles: ["webapp"]
|
||||||
|
container_name: hoppscotch-webapp-server
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
build:
|
||||||
|
dockerfile: prod.Dockerfile
|
||||||
|
context: .
|
||||||
|
target: webapp_server
|
||||||
|
ports:
|
||||||
|
- "3200:3200"
|
||||||
|
|
||||||
|
# The service that spins up all services at once in one container
|
||||||
hoppscotch-aio:
|
hoppscotch-aio:
|
||||||
|
profiles: ["default", "default-no-db"]
|
||||||
container_name: hoppscotch-aio
|
container_name: hoppscotch-aio
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
build:
|
build:
|
||||||
|
|
@ -79,12 +122,14 @@ services:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
- "3100:3100"
|
- "3100:3100"
|
||||||
- "3170:3170"
|
- "3170:3170"
|
||||||
|
- "3200:3200"
|
||||||
- "3080:80"
|
- "3080:80"
|
||||||
|
|
||||||
# The preset DB service, you can delete/comment the below lines if
|
# The preset DB service, you can delete/comment the below lines if
|
||||||
# you are using an external postgres instance
|
# you are using an external postgres instance
|
||||||
# This will be exposed at port 5432
|
# This will be exposed at port 5432
|
||||||
hoppscotch-db:
|
hoppscotch-db:
|
||||||
|
profiles: ["default", "database", "just-backend"]
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
@ -105,8 +150,24 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
# All the services listed below are deprececated
|
# Auto-migration service - handles database migrations automatically
|
||||||
|
hoppscotch-migrate:
|
||||||
|
profiles: ["default", "just-backend"]
|
||||||
|
build:
|
||||||
|
dockerfile: prod.Dockerfile
|
||||||
|
context: .
|
||||||
|
target: backend
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
depends_on:
|
||||||
|
hoppscotch-db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: sh -c "pnpx prisma migrate deploy"
|
||||||
|
|
||||||
|
# All the services listed below are deprecated
|
||||||
|
# These services are kept for backward compatibility but should not be used for new deployments
|
||||||
hoppscotch-old-backend:
|
hoppscotch-old-backend:
|
||||||
|
profiles: ["deprecated"]
|
||||||
container_name: hoppscotch-old-backend
|
container_name: hoppscotch-old-backend
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-backend/Dockerfile
|
dockerfile: packages/hoppscotch-backend/Dockerfile
|
||||||
|
|
@ -130,6 +191,7 @@ services:
|
||||||
- "3170:3000"
|
- "3170:3000"
|
||||||
|
|
||||||
hoppscotch-old-app:
|
hoppscotch-old-app:
|
||||||
|
profiles: ["deprecated"]
|
||||||
container_name: hoppscotch-old-app
|
container_name: hoppscotch-old-app
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
dockerfile: packages/hoppscotch-selfhost-web/Dockerfile
|
||||||
|
|
@ -142,6 +204,7 @@ services:
|
||||||
- "3000:8080"
|
- "3000:8080"
|
||||||
|
|
||||||
hoppscotch-old-sh-admin:
|
hoppscotch-old-sh-admin:
|
||||||
|
profiles: ["deprecated"]
|
||||||
container_name: hoppscotch-old-sh-admin
|
container_name: hoppscotch-old-sh-admin
|
||||||
build:
|
build:
|
||||||
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
dockerfile: packages/hoppscotch-sh-admin/Dockerfile
|
||||||
|
|
@ -152,3 +215,29 @@ services:
|
||||||
- hoppscotch-old-backend
|
- hoppscotch-old-backend
|
||||||
ports:
|
ports:
|
||||||
- "3100:8080"
|
- "3100:8080"
|
||||||
|
|
||||||
|
# DEPLOYMENT SCENARIOS:
|
||||||
|
# 1. Default deployment (recommended):
|
||||||
|
# docker compose --profile default up
|
||||||
|
# This will start: AIO + database + auto-migration
|
||||||
|
#
|
||||||
|
# 2. Default deployment without database:
|
||||||
|
# docker compose --profile default-no-db up
|
||||||
|
# This will start: AIO only (use when you have an external database)
|
||||||
|
#
|
||||||
|
# 3. Individual service deployment:
|
||||||
|
# docker compose --profile backend up # Just the backend
|
||||||
|
# docker compose --profile app up # Just the app
|
||||||
|
# docker compose --profile admin up # Just the admin dashboard
|
||||||
|
# docker compose --profile webapp up # Just the static web server
|
||||||
|
# docker compose --profile database up # Just the database
|
||||||
|
#
|
||||||
|
# 4. Development deployment:
|
||||||
|
# docker compose --profile just-backend up # All services except webapp
|
||||||
|
#
|
||||||
|
# 5. Deprecated services:
|
||||||
|
# docker compose --profile deprecated up
|
||||||
|
# This will start all deprecated services (not recommended for new deployments)
|
||||||
|
#
|
||||||
|
# Remember: The default and default-no-db profiles should not be mixed with individual service
|
||||||
|
# profiles as they would conflict on ports.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationDir": "./dist",
|
"declarationDir": "./dist",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"skipLibCheck": true,
|
||||||
"allowJs": true
|
"allowJs": true
|
||||||
},
|
},
|
||||||
"include": ["src/*"]
|
"include": ["src/*"]
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
|
|
||||||
|
|
||||||
use devenv
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
"@vueuse/core": "^11.1.0",
|
"@vueuse/core": "^11.1.0",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
"fp-ts": "^2.16.9",
|
"fp-ts": "^2.16.9",
|
||||||
|
"lodash-es": "4.17.21",
|
||||||
"vue": "3.3.9"
|
"vue": "3.3.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -29,6 +30,7 @@
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"unplugin-icons": "^0.19.3",
|
"unplugin-icons": "^0.19.3",
|
||||||
"vite": "^5.4.8",
|
"vite": "^5.4.8",
|
||||||
|
"@types/lodash-es": "4.17.12",
|
||||||
"vue-tsc": "^2.1.6"
|
"vue-tsc": "^2.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1645
packages/hoppscotch-agent/src-tauri/Cargo.lock
generated
1645
packages/hoppscotch-agent/src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "hoppscotch-agent"
|
name = "hoppscotch-agent"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
|
description = "A cross-platform HTTP request agent for Hoppscotch for advanced request handling including custom headers, certificates, proxies, and local system integration."
|
||||||
authors = ["AndrewBastin", "CuriousCorrelation"]
|
authors = ["AndrewBastin", "CuriousCorrelation"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
@ -29,9 +29,10 @@ tokio-util = "0.7.12"
|
||||||
uuid = { version = "1.11.0", features = [ "v4", "fast-rng" ] }
|
uuid = { version = "1.11.0", features = [ "v4", "fast-rng" ] }
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
log = "0.4.22"
|
tracing = "0.1.40"
|
||||||
env_logger = "0.11.5"
|
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "fmt", "std", "time"] }
|
||||||
hoppscotch-relay = { path = "../../hoppscotch-relay" }
|
tracing-appender = "0.2.3"
|
||||||
|
relay = { git = "https://github.com/CuriousCorrelation/relay.git" }
|
||||||
thiserror = "1.0.64"
|
thiserror = "1.0.64"
|
||||||
tauri-plugin-store = "2.1.0"
|
tauri-plugin-store = "2.1.0"
|
||||||
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
x25519-dalek = { version = "2.0.1", features = ["getrandom"] }
|
||||||
|
|
@ -43,14 +44,12 @@ lazy_static = "1.5.0"
|
||||||
tauri-plugin-single-instance = "2.0.1"
|
tauri-plugin-single-instance = "2.0.1"
|
||||||
tauri-plugin-http = { version = "2.0.1", features = ["gzip"] }
|
tauri-plugin-http = { version = "2.0.1", features = ["gzip"] }
|
||||||
native-dialog = "0.7.0"
|
native-dialog = "0.7.0"
|
||||||
|
sha2 = "0.10.8"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
tempfile = { version = "3.13.0" }
|
tempfile = { version = "3.13.0" }
|
||||||
winreg = { version = "0.52.0" }
|
winreg = { version = "0.52.0" }
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
mockito = "1.5.0"
|
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["tauri-plugin-autostart"]
|
default = ["tauri-plugin-autostart"]
|
||||||
portable = []
|
portable = []
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"core:default",
|
"core:default",
|
||||||
"shell:allow-open",
|
"shell:allow-open",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
"core:window:allow-set-focus",
|
"core:window:allow-set-focus",
|
||||||
"core:window:allow-set-always-on-top"
|
"core:window:allow-set-always-on-top"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
40
packages/hoppscotch-agent/src-tauri/src/command.rs
Normal file
40
packages/hoppscotch-agent/src-tauri/src/command.rs
Normal 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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Bytes,
|
body::Bytes,
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
|
|
@ -8,46 +10,57 @@ use axum_extra::{
|
||||||
headers::{authorization::Bearer, Authorization},
|
headers::{authorization::Bearer, Authorization},
|
||||||
TypedHeader,
|
TypedHeader,
|
||||||
};
|
};
|
||||||
use hoppscotch_relay::{RequestWithMetadata, ResponseWithMetadata};
|
use chrono::Utc;
|
||||||
use std::sync::Arc;
|
use rand::Rng;
|
||||||
|
use serde_json::json;
|
||||||
use tauri::{AppHandle, Emitter};
|
use tauri::{AppHandle, Emitter};
|
||||||
|
use uuid::Uuid;
|
||||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{AgentError, AgentResult},
|
error::{AgentError, AgentResult},
|
||||||
model::{AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse},
|
global::NONCE,
|
||||||
state::{AppState, Registration},
|
model::{
|
||||||
util::EncryptedJson,
|
AuthKeyResponse, ConfirmedRegistrationRequest, HandshakeResponse, LogEntry, LogLevel,
|
||||||
|
MaskedRegistration, Registration,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
util::{generate_auth_key_hash, EncryptedJson},
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
|
||||||
use rand::Rng;
|
|
||||||
use serde_json::json;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
|
#[tracing::instrument]
|
||||||
fn generate_otp() -> String {
|
fn generate_otp() -> String {
|
||||||
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
|
let otp: u32 = rand::thread_rng().gen_range(0..1_000_000);
|
||||||
|
let formatted = format!("{:06}", otp);
|
||||||
format!("{:06}", otp)
|
tracing::debug!("Generated OTP: {}", formatted);
|
||||||
|
formatted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(app_handle))]
|
||||||
pub async fn handshake(
|
pub async fn handshake(
|
||||||
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
State((_, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
) -> AgentResult<Json<HandshakeResponse>> {
|
) -> AgentResult<Json<HandshakeResponse>> {
|
||||||
Ok(Json(HandshakeResponse {
|
tracing::info!("Processing handshake request");
|
||||||
|
let response = HandshakeResponse {
|
||||||
status: "success".to_string(),
|
status: "success".to_string(),
|
||||||
__hoppscotch__agent__: true,
|
__hoppscotch__agent__: true,
|
||||||
agent_version: app_handle.package_info().version.to_string(),
|
agent_version: app_handle.package_info().version.to_string(),
|
||||||
}))
|
};
|
||||||
|
tracing::info!("Handshake successful");
|
||||||
|
Ok(Json(response))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, app_handle))]
|
||||||
pub async fn receive_registration(
|
pub async fn receive_registration(
|
||||||
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
) -> AgentResult<Json<serde_json::Value>> {
|
) -> AgentResult<Json<serde_json::Value>> {
|
||||||
let otp = generate_otp();
|
let otp = generate_otp();
|
||||||
|
tracing::info!("Generated new registration OTP");
|
||||||
|
|
||||||
let mut active_registration_code = state.active_registration_code.write().await;
|
let mut active_registration_code = state.active_registration_code.write().await;
|
||||||
|
|
||||||
if !active_registration_code.is_none() {
|
if !active_registration_code.is_none() {
|
||||||
|
tracing::warn!("Registration attempt while another registration is active");
|
||||||
return Ok(Json(
|
return Ok(Json(
|
||||||
json!({ "message": "There is already an existing registration happening" }),
|
json!({ "message": "There is already an existing registration happening" }),
|
||||||
));
|
));
|
||||||
|
|
@ -55,32 +68,73 @@ pub async fn receive_registration(
|
||||||
|
|
||||||
*active_registration_code = Some(otp.clone());
|
*active_registration_code = Some(otp.clone());
|
||||||
|
|
||||||
app_handle
|
match app_handle.emit("registration-received", otp) {
|
||||||
.emit("registration_received", otp)
|
Ok(_) => {
|
||||||
.map_err(|_| AgentError::InternalServerError)?;
|
tracing::info!("Registration event emitted successfully");
|
||||||
|
Ok(Json(
|
||||||
Ok(Json(
|
json!({ "message": "Registration received and stored" }),
|
||||||
json!({ "message": "Registration received and stored" }),
|
))
|
||||||
))
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to emit registration event: {}", e);
|
||||||
|
Err(AgentError::InternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, _app_handle))]
|
||||||
|
pub async fn registration(
|
||||||
|
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
|
) -> AgentResult<EncryptedJson<MaskedRegistration>> {
|
||||||
|
let token = auth_header.token();
|
||||||
|
|
||||||
|
if !state.validate_access(token) {
|
||||||
|
tracing::warn!("Unauthorized attempt to list registrations");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let registration = state
|
||||||
|
.get_registration(token)
|
||||||
|
.ok_or(AgentError::Unauthorized)?;
|
||||||
|
|
||||||
|
let key_b16 = registration.shared_secret_b16;
|
||||||
|
|
||||||
|
let registration = MaskedRegistration {
|
||||||
|
registered_at: registration.registered_at,
|
||||||
|
auth_key_hash: generate_auth_key_hash(token),
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("Successfully retrieved registrations list");
|
||||||
|
Ok(EncryptedJson {
|
||||||
|
key_b16,
|
||||||
|
data: registration,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, app_handle), fields(auth_key))]
|
||||||
pub async fn verify_registration(
|
pub async fn verify_registration(
|
||||||
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
|
Json(confirmed_registration): Json<ConfirmedRegistrationRequest>,
|
||||||
) -> AgentResult<Json<AuthKeyResponse>> {
|
) -> AgentResult<Json<AuthKeyResponse>> {
|
||||||
state
|
tracing::info!("Verifying registration request");
|
||||||
|
|
||||||
|
if !state
|
||||||
.validate_registration(&confirmed_registration.registration)
|
.validate_registration(&confirmed_registration.registration)
|
||||||
.await
|
.await
|
||||||
.then_some(())
|
{
|
||||||
.ok_or(AgentError::InvalidRegistration)?;
|
tracing::warn!("Invalid registration attempt");
|
||||||
|
return Err(AgentError::InvalidRegistration);
|
||||||
|
}
|
||||||
|
|
||||||
let auth_key = Uuid::new_v4().to_string();
|
let auth_key = Uuid::new_v4().to_string();
|
||||||
let created_at = Utc::now();
|
let created_at = Utc::now();
|
||||||
|
|
||||||
let auth_key_copy = auth_key.clone();
|
tracing::Span::current().record("auth_key", &auth_key.as_str());
|
||||||
|
|
||||||
let agent_secret_key = EphemeralSecret::random();
|
let auth_key_copy = auth_key.clone();
|
||||||
let agent_public_key = PublicKey::from(&agent_secret_key);
|
let secret_key = EphemeralSecret::random();
|
||||||
|
let public_key = PublicKey::from(&secret_key);
|
||||||
|
|
||||||
let their_public_key = {
|
let their_public_key = {
|
||||||
let public_key_slice: &[u8; 32] =
|
let public_key_slice: &[u8; 32] =
|
||||||
|
|
@ -92,9 +146,9 @@ pub async fn verify_registration(
|
||||||
PublicKey::from(public_key_slice.to_owned())
|
PublicKey::from(public_key_slice.to_owned())
|
||||||
};
|
};
|
||||||
|
|
||||||
let shared_secret = agent_secret_key.diffie_hellman(&their_public_key);
|
let shared_secret = secret_key.diffie_hellman(&their_public_key);
|
||||||
|
|
||||||
let _ = state.update_registrations(app_handle.clone(), |regs| {
|
if let Err(e) = state.update_registrations(app_handle.clone(), |regs| {
|
||||||
regs.insert(
|
regs.insert(
|
||||||
auth_key_copy,
|
auth_key_copy,
|
||||||
Registration {
|
Registration {
|
||||||
|
|
@ -102,82 +156,102 @@ pub async fn verify_registration(
|
||||||
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()),
|
shared_secret_b16: base16::encode_lower(shared_secret.as_bytes()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})?;
|
}) {
|
||||||
|
tracing::error!("Failed to update registrations: {:?}", e);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
let auth_payload = json!({
|
let auth_payload = json!({
|
||||||
"auth_key": auth_key,
|
"auth_key": auth_key,
|
||||||
"created_at": created_at
|
"created_at": created_at
|
||||||
});
|
});
|
||||||
|
|
||||||
app_handle
|
if let Err(e) = app_handle.emit("authenticated", &auth_payload) {
|
||||||
.emit("authenticated", &auth_payload)
|
tracing::error!("Failed to emit authenticated event: {:?}", e);
|
||||||
.map_err(|_| AgentError::InternalServerError)?;
|
return Err(AgentError::InternalServerError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = state.clear_active_registration().await;
|
||||||
|
|
||||||
|
tracing::info!("Registration verified successfully");
|
||||||
Ok(Json(AuthKeyResponse {
|
Ok(Json(AuthKeyResponse {
|
||||||
auth_key,
|
auth_key,
|
||||||
created_at,
|
created_at,
|
||||||
agent_public_key_b16: base16::encode_lower(agent_public_key.as_bytes()),
|
agent_public_key_b16: base16::encode_lower(public_key.as_bytes()),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn run_request<T>(
|
#[tracing::instrument(skip(state, app_handle), fields(auth_key = %auth_key))]
|
||||||
State((state, _app_handle)): State<(Arc<AppState>, T)>,
|
pub async fn delete_registration(
|
||||||
|
State((state, app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
|
Path(auth_key): Path<String>,
|
||||||
|
) -> AgentResult<Json<serde_json::Value>> {
|
||||||
|
if !state.validate_access(auth_header.token()) {
|
||||||
|
tracing::warn!("Unauthorized deletion attempt");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _removed = state.update_registrations(app_handle.clone(), |regs| {
|
||||||
|
regs.remove(&auth_key);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
tracing::info!("Registration deleted successfully");
|
||||||
|
let message = format!("{} registration deleted successfully", auth_key);
|
||||||
|
Ok(Json(json!({ "message": message })))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, body, _app_handle), fields(req_id))]
|
||||||
|
pub async fn execute(
|
||||||
|
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
body: Bytes,
|
body: Bytes,
|
||||||
) -> AgentResult<EncryptedJson<ResponseWithMetadata>> {
|
) -> AgentResult<EncryptedJson<relay::Response>> {
|
||||||
let nonce = headers
|
let nonce = match headers.get(NONCE) {
|
||||||
.get("X-Hopp-Nonce")
|
Some(n) => match n.to_str() {
|
||||||
.ok_or(AgentError::Unauthorized)?
|
Ok(n) => n,
|
||||||
.to_str()
|
Err(_) => {
|
||||||
.map_err(|_| AgentError::Unauthorized)?;
|
tracing::warn!("Invalid nonce header");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
let req: RequestWithMetadata = state
|
|
||||||
.validate_access_and_get_data(auth_header.token(), nonce, &body)
|
|
||||||
.ok_or(AgentError::Unauthorized)?;
|
|
||||||
|
|
||||||
let req_id = req.req_id;
|
|
||||||
|
|
||||||
let reg_info = state
|
|
||||||
.get_registration_info(auth_header.token())
|
|
||||||
.ok_or(AgentError::Unauthorized)?;
|
|
||||||
|
|
||||||
let cancel_token = tokio_util::sync::CancellationToken::new();
|
|
||||||
state.add_cancellation_token(req.req_id, cancel_token.clone());
|
|
||||||
|
|
||||||
let cancel_token_clone = cancel_token.clone();
|
|
||||||
// Execute the HTTP request in a blocking thread pool and handles cancellation.
|
|
||||||
//
|
|
||||||
// It:
|
|
||||||
// 1. Uses `spawn_blocking` to run the sync `run_request_task`
|
|
||||||
// without blocking the main Tokio runtime.
|
|
||||||
// 2. Uses `select!` to concurrently wait for either
|
|
||||||
// a. the task to complete,
|
|
||||||
// b. or a cancellation signal.
|
|
||||||
//
|
|
||||||
// Why spawn_blocking?
|
|
||||||
// - `run_request_task` uses synchronous curl operations which would block
|
|
||||||
// the async runtime if not run in a separate thread.
|
|
||||||
// - `spawn_blocking` moves this operation to a thread pool designed for
|
|
||||||
// blocking tasks, so other async operations to continue unblocked.
|
|
||||||
let result = tokio::select! {
|
|
||||||
res = tokio::task::spawn_blocking(move || hoppscotch_relay::run_request_task(&req, cancel_token_clone)) => {
|
|
||||||
match res {
|
|
||||||
Ok(task_result) => Ok(task_result?),
|
|
||||||
Err(_) => Err(AgentError::InternalServerError),
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
_ = cancel_token.cancelled() => {
|
None => {
|
||||||
Err(AgentError::RequestCancelled)
|
tracing::warn!("Missing nonce header");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
state.remove_cancellation_token(req_id);
|
let request = match state.validate_access_and_get_data::<relay::Request>(
|
||||||
|
auth_header.token(),
|
||||||
|
nonce,
|
||||||
|
&body,
|
||||||
|
) {
|
||||||
|
Some(r) => r,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Invalid access or data");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
result.map(|val| EncryptedJson {
|
let request_id = request.id;
|
||||||
key_b16: reg_info.shared_secret_b16,
|
|
||||||
data: val,
|
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: response,
|
||||||
|
})?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Provides a way for registered clients to check if their
|
/// Provides a way for registered clients to check if their
|
||||||
|
|
@ -187,34 +261,139 @@ pub async fn run_request<T>(
|
||||||
/// registration, the client also needs the shared secret to verify
|
/// registration, the client also needs the shared secret to verify
|
||||||
/// if the read fails, or the auth_key didn't validate and this route returns
|
/// if the read fails, or the auth_key didn't validate and this route returns
|
||||||
/// undefined, we can count on the registration not being valid anymore.
|
/// undefined, we can count on the registration not being valid anymore.
|
||||||
|
#[tracing::instrument(skip(state, _app_handle))]
|
||||||
pub async fn registered_handshake(
|
pub async fn registered_handshake(
|
||||||
State((state, _)): State<(Arc<AppState>, AppHandle)>,
|
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
) -> AgentResult<EncryptedJson<serde_json::Value>> {
|
) -> AgentResult<EncryptedJson<serde_json::Value>> {
|
||||||
let reg_info = state.get_registration_info(auth_header.token());
|
let reg_info = state.get_registration(auth_header.token());
|
||||||
|
|
||||||
match reg_info {
|
match reg_info {
|
||||||
Some(reg) => Ok(EncryptedJson {
|
Some(reg) => {
|
||||||
key_b16: reg.shared_secret_b16,
|
tracing::info!("Handshake successful");
|
||||||
data: json!(true),
|
Ok(EncryptedJson {
|
||||||
}),
|
key_b16: reg.shared_secret_b16,
|
||||||
None => Err(AgentError::Unauthorized),
|
data: json!(true),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Unauthorized handshake attempt");
|
||||||
|
Err(AgentError::Unauthorized)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn cancel_request<T>(
|
#[tracing::instrument(skip(state, _app_handle), fields(request_id = %request_id))]
|
||||||
State((state, _app_handle)): State<(Arc<AppState>, T)>,
|
pub async fn cancel(
|
||||||
|
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
Path(req_id): Path<usize>,
|
Path(request_id): Path<usize>,
|
||||||
) -> AgentResult<Json<serde_json::Value>> {
|
) -> AgentResult<Json<serde_json::Value>> {
|
||||||
if !state.validate_access(auth_header.token()) {
|
if !state.validate_access(auth_header.token()) {
|
||||||
|
tracing::warn!("Unauthorized cancellation attempt");
|
||||||
return Err(AgentError::Unauthorized);
|
return Err(AgentError::Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some((_, token)) = state.remove_cancellation_token(req_id) {
|
if let Ok(()) = relay::cancel(request_id.try_into().unwrap()).await {
|
||||||
token.cancel();
|
tracing::info!("Request cancelled successfully");
|
||||||
Ok(Json(json!({"message": "Request cancelled successfully"})))
|
Ok(Json(json!({"message": "Request cancelled successfully"})))
|
||||||
} else {
|
} else {
|
||||||
|
tracing::warn!("Request not found");
|
||||||
Err(AgentError::RequestNotFound)
|
Err(AgentError::RequestNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip_all)]
|
||||||
|
pub async fn log_sink(
|
||||||
|
State((state, _app_handle)): State<(Arc<AppState>, AppHandle)>,
|
||||||
|
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
body: Bytes,
|
||||||
|
) -> AgentResult<Json<serde_json::Value>> {
|
||||||
|
if !state.validate_access(auth_header.token()) {
|
||||||
|
tracing::warn!("Unauthorized log sink access attempt");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
let nonce = match headers.get(NONCE) {
|
||||||
|
Some(n) => match n.to_str() {
|
||||||
|
Ok(n) => n,
|
||||||
|
Err(_) => {
|
||||||
|
tracing::warn!("Invalid nonce header");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Missing nonce header");
|
||||||
|
return Err(AgentError::Unauthorized);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let log_entry: LogEntry =
|
||||||
|
match state.validate_access_and_get_data(auth_header.token(), nonce, &body) {
|
||||||
|
Some(entry) => entry,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Failed to decrypt or parse log entry");
|
||||||
|
return Err(AgentError::BadRequest("Invalid log entry format".into()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let metadata_str = log_entry
|
||||||
|
.metadata
|
||||||
|
.map(|m| m.to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let correlation = log_entry.correlation_id.unwrap_or_default();
|
||||||
|
|
||||||
|
match log_entry.level {
|
||||||
|
LogLevel::Debug => {
|
||||||
|
tracing::debug!(
|
||||||
|
timestamp = %log_entry.timestamp,
|
||||||
|
context = %log_entry.context,
|
||||||
|
source = %log_entry.source,
|
||||||
|
metadata = %metadata_str,
|
||||||
|
correlation_id = %correlation,
|
||||||
|
"{}",
|
||||||
|
log_entry.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LogLevel::Info => {
|
||||||
|
tracing::info!(
|
||||||
|
timestamp = %log_entry.timestamp,
|
||||||
|
context = %log_entry.context,
|
||||||
|
source = %log_entry.source,
|
||||||
|
metadata = %metadata_str,
|
||||||
|
correlation_id = %correlation,
|
||||||
|
"{}",
|
||||||
|
log_entry.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LogLevel::Warn => {
|
||||||
|
tracing::warn!(
|
||||||
|
timestamp = %log_entry.timestamp,
|
||||||
|
context = %log_entry.context,
|
||||||
|
source = %log_entry.source,
|
||||||
|
metadata = %metadata_str,
|
||||||
|
correlation_id = %correlation,
|
||||||
|
"{}",
|
||||||
|
log_entry.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
LogLevel::Error => {
|
||||||
|
tracing::error!(
|
||||||
|
timestamp = %log_entry.timestamp,
|
||||||
|
context = %log_entry.context,
|
||||||
|
source = %log_entry.source,
|
||||||
|
metadata = %metadata_str,
|
||||||
|
correlation_id = %correlation,
|
||||||
|
"{}",
|
||||||
|
log_entry.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(json!({
|
||||||
|
"status": "success",
|
||||||
|
"message": "Log entry processed"
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ pub fn panic(msg: &str) {
|
||||||
.show_alert()
|
.show_alert()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
log::error!("{}: {}", FATAL_ERROR, msg);
|
tracing::error!("{}: {}", FATAL_ERROR, msg);
|
||||||
|
|
||||||
panic!("{}: {}", FATAL_ERROR, msg);
|
panic!("{}: {}", FATAL_ERROR, msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn info(msg: &str) {
|
pub fn info(msg: &str) {
|
||||||
log::info!("{}", msg);
|
tracing::info!("{}", msg);
|
||||||
|
|
||||||
MessageDialog::new()
|
MessageDialog::new()
|
||||||
.set_type(MessageType::Info)
|
.set_type(MessageType::Info)
|
||||||
|
|
@ -27,7 +27,7 @@ pub fn info(msg: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn warn(msg: &str) {
|
pub fn warn(msg: &str) {
|
||||||
log::warn!("{}", msg);
|
tracing::warn!("{}", msg);
|
||||||
|
|
||||||
MessageDialog::new()
|
MessageDialog::new()
|
||||||
.set_type(MessageType::Warning)
|
.set_type(MessageType::Warning)
|
||||||
|
|
@ -38,7 +38,7 @@ pub fn warn(msg: &str) {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn error(msg: &str) {
|
pub fn error(msg: &str) {
|
||||||
log::error!("{}", msg);
|
tracing::error!("{}", msg);
|
||||||
|
|
||||||
MessageDialog::new()
|
MessageDialog::new()
|
||||||
.set_type(MessageType::Error)
|
.set_type(MessageType::Error)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum AgentError {
|
pub enum AgentError {
|
||||||
|
#[error("FATAL: No `main` window found")]
|
||||||
|
NoMainWindow,
|
||||||
|
#[error("Tauri error: {0}")]
|
||||||
|
Tauri(#[from] tauri::Error),
|
||||||
#[error("Invalid Registration")]
|
#[error("Invalid Registration")]
|
||||||
InvalidRegistration,
|
InvalidRegistration,
|
||||||
#[error("Invalid Client Public Key")]
|
#[error("Invalid Client Public Key")]
|
||||||
|
|
@ -45,7 +49,13 @@ pub enum AgentError {
|
||||||
#[error("Store error: {0}")]
|
#[error("Store error: {0}")]
|
||||||
TauriPluginStore(#[from] tauri_plugin_store::Error),
|
TauriPluginStore(#[from] tauri_plugin_store::Error),
|
||||||
#[error("Relay error: {0}")]
|
#[error("Relay error: {0}")]
|
||||||
Relay(#[from] hoppscotch_relay::RelayError),
|
Relay(#[from] relay::error::RelayError),
|
||||||
|
#[error("IO error: {0}")]
|
||||||
|
Io(#[from] std::io::Error),
|
||||||
|
#[error("Log init error: {0}")]
|
||||||
|
LogInit(#[from] tracing_appender::rolling::InitError),
|
||||||
|
#[error("Log init global error: {0}")]
|
||||||
|
LogInitGlobal(#[from] tracing::subscriber::SetGlobalDefaultError),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl IntoResponse for AgentError {
|
impl IntoResponse for AgentError {
|
||||||
|
|
@ -55,7 +65,9 @@ impl IntoResponse for AgentError {
|
||||||
AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
|
AgentError::InvalidClientPublicKey => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
AgentError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
|
||||||
AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
AgentError::RequestNotFound => (StatusCode::NOT_FOUND, self.to_string()),
|
||||||
AgentError::InternalServerError => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
|
AgentError::InternalServerError => {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, self.to_string())
|
||||||
|
}
|
||||||
AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
AgentError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
|
||||||
AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
AgentError::ClientCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
AgentError::RootCertError => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
pub const AGENT_STORE: &str = "app_data.bin";
|
pub const AGENT_STORE: &str = "app_data.bin";
|
||||||
pub const REGISTRATIONS: &str = "registrations";
|
pub const REGISTRATIONS: &str = "registrations";
|
||||||
|
pub const NONCE: &str = "X-Hopp-Nonce";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
pub mod command;
|
||||||
pub mod controller;
|
pub mod controller;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
@ -11,59 +12,105 @@ pub mod updater;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
pub mod webview;
|
pub mod webview;
|
||||||
|
|
||||||
use log::{error, info};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{Emitter, Listener, Manager, WebviewWindowBuilder};
|
use tauri::{AppHandle, Emitter, Listener, Manager, WebviewWindowBuilder};
|
||||||
use tauri_plugin_updater::UpdaterExt;
|
use tauri_plugin_updater::UpdaterExt;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
use tracing_subscriber::{fmt::format::JsonFields, EnvFilter};
|
||||||
|
|
||||||
use model::Payload;
|
use error::{AgentError, AgentResult};
|
||||||
|
use model::{LogGuard, Payload};
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tracing::instrument(skip(app_handle))]
|
||||||
async fn get_otp(state: tauri::State<'_, Arc<AppState>>) -> Result<Option<String>, ()> {
|
fn create_main_window(app_handle: &AppHandle) -> AgentResult<()> {
|
||||||
Ok(state.active_registration_code.read().await.clone())
|
tracing::info!("Creating main application window");
|
||||||
|
|
||||||
|
let main = &app_handle
|
||||||
|
.config()
|
||||||
|
.app
|
||||||
|
.windows
|
||||||
|
.first()
|
||||||
|
.ok_or(AgentError::NoMainWindow)?;
|
||||||
|
|
||||||
|
tracing::debug!("Building webview window from config");
|
||||||
|
let window = WebviewWindowBuilder::from_config(app_handle, main)?.build()?;
|
||||||
|
|
||||||
|
window.hide()?;
|
||||||
|
|
||||||
|
tracing::info!("Main window created successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(app_handle))]
|
||||||
|
pub fn show_main_window(app_handle: &AppHandle) -> AgentResult<()> {
|
||||||
|
tracing::debug!("Attempting to show main window");
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
window.show()?;
|
||||||
|
window.set_focus()?;
|
||||||
|
tracing::info!("Main window shown and focused");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(app_handle))]
|
||||||
|
pub fn hide_main_window(app_handle: &AppHandle) -> AgentResult<()> {
|
||||||
|
tracing::debug!("Attempting to hide main window");
|
||||||
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
|
window.hide()?;
|
||||||
|
tracing::info!("Main window hidden");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
env_logger::init();
|
tracing::info!("Initializing Hoppscotch Agent");
|
||||||
|
|
||||||
// The installer takes care of installing `WebView`,
|
// The installer takes care of installing `WebView`,
|
||||||
// this check is only required for portable variant.
|
// this check is only required for portable variant.
|
||||||
#[cfg(all(feature = "portable", windows))]
|
#[cfg(all(feature = "portable", windows))]
|
||||||
webview::init_webview();
|
{
|
||||||
|
tracing::debug!("Checking WebView initialization for portable Windows variant");
|
||||||
|
webview::init_webview();
|
||||||
|
}
|
||||||
|
|
||||||
let cancellation_token = CancellationToken::new();
|
let cancellation_token = CancellationToken::new();
|
||||||
let server_cancellation_token = cancellation_token.clone();
|
let server_cancellation_token = cancellation_token.clone();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tracing::debug!("Building Tauri application");
|
||||||
|
let builder = tauri::Builder::default()
|
||||||
// NOTE: Currently, plugins run in the order they were added in to the builder,
|
// NOTE: Currently, plugins run in the order they were added in to the builder,
|
||||||
// so `tauri_plugin_single_instance` needs to be registered first.
|
// so `tauri_plugin_single_instance` needs to be registered first.
|
||||||
// See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance
|
// See: https://github.com/tauri-apps/plugins-workspace/tree/v2/plugins/single-instance
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||||
info!("{}, {args:?}, {cwd}", app.package_info().name);
|
tracing::info!(
|
||||||
|
app_name = %app.package_info().name,
|
||||||
|
"Single instance handler triggered"
|
||||||
|
);
|
||||||
|
|
||||||
app.emit("single-instance", Payload::new(args, cwd))
|
if let Err(e) = app.emit("single-instance", Payload::new(args, cwd)) {
|
||||||
.unwrap();
|
tracing::error!(error = %e, "Failed to emit single-instance event");
|
||||||
|
}
|
||||||
|
|
||||||
// Application is already running, bring it to foreground.
|
// Application is already running, bring it to foreground.
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Err(e) = show_main_window(&app) {
|
||||||
let _ = window.show();
|
tracing::error!(error = %e, "Failed to show window");
|
||||||
let _ = window.set_focus();
|
|
||||||
} else {
|
|
||||||
error!("Failed to get `main` window");
|
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
.plugin(tauri_plugin_store::Builder::new().build())
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
let app_handle = app.app_handle();
|
// let _ = setup_logging(&app.handle())?;
|
||||||
|
|
||||||
|
tracing::info!("Setting up application");
|
||||||
|
let app_handle = app.handle();
|
||||||
|
|
||||||
#[cfg(all(desktop, not(feature = "portable")))]
|
#[cfg(all(desktop, not(feature = "portable")))]
|
||||||
{
|
{
|
||||||
use tauri_plugin_autostart::MacosLauncher;
|
use tauri_plugin_autostart::MacosLauncher;
|
||||||
use tauri_plugin_autostart::ManagerExt;
|
use tauri_plugin_autostart::ManagerExt;
|
||||||
|
|
||||||
|
tracing::debug!("Configuring autostart for desktop variant");
|
||||||
let _ = app.handle().plugin(tauri_plugin_autostart::init(
|
let _ = app.handle().plugin(tauri_plugin_autostart::init(
|
||||||
MacosLauncher::LaunchAgent,
|
MacosLauncher::LaunchAgent,
|
||||||
None,
|
None,
|
||||||
|
|
@ -71,30 +118,29 @@ pub fn run() {
|
||||||
|
|
||||||
let autostart_manager = app.autolaunch();
|
let autostart_manager = app.autolaunch();
|
||||||
|
|
||||||
println!(
|
tracing::info!(
|
||||||
"autostart enabled: {}",
|
enabled = autostart_manager.is_enabled().unwrap_or(false),
|
||||||
autostart_manager.is_enabled().unwrap()
|
"Checking autostart status"
|
||||||
);
|
);
|
||||||
|
|
||||||
if !autostart_manager.is_enabled().unwrap() {
|
if !autostart_manager.is_enabled().unwrap_or(false) {
|
||||||
let _ = autostart_manager.enable();
|
if let Err(e) = autostart_manager.enable() {
|
||||||
println!(
|
tracing::error!(error = %e, "Failed to enable autostart");
|
||||||
"autostart updated: {}",
|
} else {
|
||||||
autostart_manager.is_enabled().unwrap()
|
tracing::info!("Autostart enabled successfully");
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(desktop)]
|
#[cfg(desktop)]
|
||||||
{
|
{
|
||||||
|
tracing::debug!("Initializing desktop-specific features");
|
||||||
let _ = app
|
let _ = app
|
||||||
.handle()
|
.handle()
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build());
|
.plugin(tauri_plugin_updater::Builder::new().build());
|
||||||
|
|
||||||
let _ = app.handle().plugin(tauri_plugin_dialog::init());
|
let _ = app.handle().plugin(tauri_plugin_dialog::init());
|
||||||
|
|
||||||
let updater = app.updater_builder().build().unwrap();
|
let updater = app.updater_builder().build().unwrap();
|
||||||
|
|
||||||
let app_handle_ref = app_handle.clone();
|
let app_handle_ref = app_handle.clone();
|
||||||
|
|
||||||
tauri::async_runtime::spawn_blocking(|| {
|
tauri::async_runtime::spawn_blocking(|| {
|
||||||
|
|
@ -104,20 +150,24 @@ pub fn run() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
let app_state = Arc::new(AppState::new(app_handle.clone())?);
|
// Create and hide the main window during setup.
|
||||||
|
create_main_window(&app_handle)?;
|
||||||
|
|
||||||
|
tracing::debug!("Initializing application state");
|
||||||
|
let app_state = Arc::new(AppState::new(app_handle.clone())?);
|
||||||
app.manage(app_state.clone());
|
app.manage(app_state.clone());
|
||||||
|
|
||||||
let server_cancellation_token = server_cancellation_token.clone();
|
let server_cancellation_token = server_cancellation_token.clone();
|
||||||
|
|
||||||
let server_app_handle = app_handle.clone();
|
let server_app_handle = app_handle.clone();
|
||||||
|
|
||||||
|
tracing::debug!("Spawning server process");
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
server::run_server(app_state, server_cancellation_token, server_app_handle).await;
|
server::run_server(app_state, server_cancellation_token, server_app_handle).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
#[cfg(all(desktop))]
|
#[cfg(all(desktop))]
|
||||||
{
|
{
|
||||||
|
tracing::debug!("Creating system tray");
|
||||||
let handle = app.handle();
|
let handle = app.handle();
|
||||||
tray::create_tray(handle)?;
|
tray::create_tray(handle)?;
|
||||||
}
|
}
|
||||||
|
|
@ -125,54 +175,116 @@ pub fn run() {
|
||||||
// Blocks the app from populating the macOS dock
|
// Blocks the app from populating the macOS dock
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
tracing::debug!("Setting macOS activation policy");
|
||||||
app_handle
|
app_handle
|
||||||
.set_activation_policy(tauri::ActivationPolicy::Accessory)
|
.set_activation_policy(tauri::ActivationPolicy::Accessory)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
};
|
};
|
||||||
|
|
||||||
let app_handle_ref = app_handle.clone();
|
let app_handle_ref = app_handle.clone();
|
||||||
|
app_handle.listen("registration-received", move |_| {
|
||||||
app_handle.listen("registration_received", move |_| {
|
tracing::info!("Registration received event triggered");
|
||||||
WebviewWindowBuilder::from_config(
|
if let Err(e) = show_main_window(&app_handle_ref) {
|
||||||
&app_handle_ref,
|
tracing::error!(error = %e, "Failed to show window");
|
||||||
&app_handle_ref.config().app.windows[0],
|
}
|
||||||
)
|
|
||||||
.unwrap()
|
|
||||||
.build()
|
|
||||||
.unwrap()
|
|
||||||
.show()
|
|
||||||
.unwrap();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tracing::info!("Application setup completed successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.manage(cancellation_token)
|
.manage(cancellation_token)
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
match &event {
|
match &event {
|
||||||
tauri::WindowEvent::CloseRequested { .. } => {
|
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||||
|
tracing::info!("Window close requested");
|
||||||
|
api.prevent_close();
|
||||||
|
|
||||||
|
if let Err(e) = window.hide() {
|
||||||
|
tracing::error!(error = %e, "Failed to hide window");
|
||||||
|
}
|
||||||
|
|
||||||
let app_state = window.state::<Arc<AppState>>();
|
let app_state = window.state::<Arc<AppState>>();
|
||||||
|
|
||||||
let mut current_code = app_state.active_registration_code.blocking_write();
|
let mut current_code = app_state.active_registration_code.blocking_write();
|
||||||
|
|
||||||
if current_code.is_some() {
|
if current_code.is_some() {
|
||||||
|
tracing::debug!("Clearing active registration code");
|
||||||
*current_code = None;
|
*current_code = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(e) = window.emit("window-hidden", ()) {
|
||||||
|
tracing::error!(error = %e, "Failed to emit window-hidden event");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
tracing::debug!(event = ?event, "Window event received");
|
||||||
}
|
}
|
||||||
_ => {}
|
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![get_otp])
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
command::get_otp,
|
||||||
|
command::list_registrations
|
||||||
|
]);
|
||||||
|
|
||||||
|
tracing::info!("Building Tauri application with context");
|
||||||
|
let app = builder
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application");
|
||||||
.run(|app_handle, event| match event {
|
|
||||||
tauri::RunEvent::ExitRequested { api, code, .. } => {
|
tracing::info!("Running application");
|
||||||
if code.is_none() || matches!(code, Some(0)) {
|
app.run(|app_handle, event| match event {
|
||||||
api.prevent_exit()
|
tauri::RunEvent::ExitRequested { api, code, .. } => {
|
||||||
} else if code.is_some() {
|
if code.is_none() || matches!(code, Some(0)) {
|
||||||
let state = app_handle.state::<CancellationToken>();
|
tracing::info!("Exit requested, preventing immediate exit");
|
||||||
state.cancel();
|
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();
|
||||||
}
|
}
|
||||||
_ => {}
|
}
|
||||||
});
|
_ => {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,18 @@
|
||||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| format!("{}=debug", env!("CARGO_CRATE_NAME")).into()),
|
||||||
|
)
|
||||||
|
.with(tracing_subscriber::fmt::layer().without_time())
|
||||||
|
.init();
|
||||||
|
|
||||||
|
tracing::info!("Starting Hoppscotch Agent...");
|
||||||
|
|
||||||
hoppscotch_agent_lib::run()
|
hoppscotch_agent_lib::run()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,42 @@
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
/// Describes one registered app instance
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Registration {
|
||||||
|
pub registered_at: DateTime<Utc>,
|
||||||
|
|
||||||
|
/// base16 (lowercase) encoded shared secret that the client
|
||||||
|
/// and agent established during registration that is used
|
||||||
|
/// to encrypt traffic between them
|
||||||
|
pub shared_secret_b16: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MaskedRegistration {
|
||||||
|
pub registered_at: DateTime<Utc>,
|
||||||
|
pub auth_key_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(&String, &Registration)> for MaskedRegistration {
|
||||||
|
fn from((key, registration): (&String, &Registration)) -> Self {
|
||||||
|
let hash = Sha256::digest(key.as_bytes());
|
||||||
|
let short_hash = base16::encode_lower(&hash[..3]);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
registered_at: registration.registered_at,
|
||||||
|
auth_key_hash: short_hash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RegistrationsList {
|
||||||
|
pub registrations: Vec<MaskedRegistration>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
/// Single instance payload.
|
/// Single instance payload.
|
||||||
#[derive(Clone, Serialize)]
|
#[derive(Clone, Serialize)]
|
||||||
|
|
@ -45,3 +82,29 @@ pub struct AuthKeyResponse {
|
||||||
/// and client after registration
|
/// and client after registration
|
||||||
pub agent_public_key_b16: String,
|
pub agent_public_key_b16: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A logger guard, managed by tauri runtime to make sure
|
||||||
|
/// logger doesn't get cleaned up or dropped during app's run time.
|
||||||
|
pub struct LogGuard(pub tracing_appender::non_blocking::WorkerGuard);
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct LogEntry {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub level: LogLevel,
|
||||||
|
pub context: String,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub metadata: Option<Value>,
|
||||||
|
pub source: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub correlation_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "UPPERCASE")]
|
||||||
|
pub enum LogLevel {
|
||||||
|
Debug,
|
||||||
|
Info,
|
||||||
|
Warn,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
routing::{get, post},
|
routing::{delete, get, post},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
@ -22,7 +22,13 @@ pub fn route(state: Arc<AppState>, app_handle: AppHandle) -> Router {
|
||||||
"/registered-handshake",
|
"/registered-handshake",
|
||||||
get(controller::registered_handshake),
|
get(controller::registered_handshake),
|
||||||
)
|
)
|
||||||
.route("/request", post(controller::run_request))
|
.route("/registration", get(controller::registration))
|
||||||
.route("/cancel-request/:req_id", post(controller::cancel_request))
|
.route(
|
||||||
|
"/registrations/:auth_key",
|
||||||
|
delete(controller::delete_registration),
|
||||||
|
)
|
||||||
|
.route("/execute", post(controller::execute))
|
||||||
|
.route("/cancel/:req_id", post(controller::cancel))
|
||||||
|
.route("/log-sink", post(controller::log_sink))
|
||||||
.with_state((state, app_handle))
|
.with_state((state, app_handle))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ use tower_http::cors::CorsLayer;
|
||||||
use crate::route;
|
use crate::route;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(state, cancellation_token, app_handle))]
|
||||||
pub async fn run_server(
|
pub async fn run_server(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
cancellation_token: CancellationToken,
|
cancellation_token: CancellationToken,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) {
|
) {
|
||||||
|
tracing::info!("Initializing server");
|
||||||
let cors = CorsLayer::permissive();
|
let cors = CorsLayer::permissive();
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
|
|
@ -18,17 +20,31 @@ pub async fn run_server(
|
||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
|
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], 9119));
|
||||||
|
tracing::info!(address = %addr, "Starting server");
|
||||||
|
|
||||||
println!("Server running on http://{}", addr);
|
match tokio::net::TcpListener::bind(&addr).await {
|
||||||
|
Ok(listener) => {
|
||||||
|
tracing::info!(address = %addr, "Server bound successfully");
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
if let Err(e) = axum::serve(listener, app.into_make_service())
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
cancellation_token.cancelled().await;
|
||||||
|
tracing::info!("Graceful shutdown initiated");
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = %e, "Server error occurred");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
axum::serve(listener, app.into_make_service())
|
tracing::info!("Server shut down successfully");
|
||||||
.with_graceful_shutdown(async move {
|
}
|
||||||
cancellation_token.cancelled().await;
|
Err(e) => {
|
||||||
})
|
tracing::error!(
|
||||||
.await
|
error = %e,
|
||||||
.unwrap();
|
address = %addr,
|
||||||
|
"Failed to bind server to address"
|
||||||
println!("Server shut down");
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
|
use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit};
|
||||||
use axum::body::Bytes;
|
use axum::body::Bytes;
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
use serde::de::DeserializeOwned;
|
||||||
use tauri_plugin_store::StoreExt;
|
use tauri_plugin_store::StoreExt;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
@ -10,20 +9,10 @@ use tokio_util::sync::CancellationToken;
|
||||||
use crate::{
|
use crate::{
|
||||||
error::{AgentError, AgentResult},
|
error::{AgentError, AgentResult},
|
||||||
global::{AGENT_STORE, REGISTRATIONS},
|
global::{AGENT_STORE, REGISTRATIONS},
|
||||||
|
model::Registration,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Describes one registered app instance
|
#[derive(Debug, Default)]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Registration {
|
|
||||||
pub registered_at: DateTime<Utc>,
|
|
||||||
|
|
||||||
/// base16 (lowercase) encoded shared secret that the client
|
|
||||||
/// and agent established during registration that is used
|
|
||||||
/// to encrypt traffic between them
|
|
||||||
pub shared_secret_b16: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default)]
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
/// The active registration code that is being registered.
|
/// The active registration code that is being registered.
|
||||||
pub active_registration_code: RwLock<Option<String>>,
|
pub active_registration_code: RwLock<Option<String>>,
|
||||||
|
|
@ -37,19 +26,36 @@ pub struct AppState {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
|
#[tracing::instrument(skip(app_handle))]
|
||||||
pub fn new(app_handle: tauri::AppHandle) -> AgentResult<Self> {
|
pub fn new(app_handle: tauri::AppHandle) -> AgentResult<Self> {
|
||||||
let store = app_handle.store(AGENT_STORE)?;
|
tracing::info!("Initializing application state");
|
||||||
|
let store = match app_handle.store(AGENT_STORE) {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to access app store: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Try loading and parsing registrations from the store, if that failed,
|
// Try loading and parsing registrations from the store, if that failed,
|
||||||
// load the default list
|
// load the default list
|
||||||
let registrations = store
|
let registrations = store
|
||||||
.get(REGISTRATIONS)
|
.get(REGISTRATIONS)
|
||||||
.and_then(|val| serde_json::from_value(val.clone()).ok())
|
.and_then(|val| serde_json::from_value(val.clone()).ok())
|
||||||
.unwrap_or_else(|| DashMap::new());
|
.unwrap_or_else(|| {
|
||||||
|
tracing::debug!("No existing registrations found, initializing empty map");
|
||||||
|
DashMap::new()
|
||||||
|
});
|
||||||
|
|
||||||
// Try to save the latest registrations list
|
// Try to save the latest registrations list
|
||||||
let _ = store.set(REGISTRATIONS, serde_json::to_value(®istrations)?);
|
let _ = store.set(REGISTRATIONS, serde_json::to_value(®istrations)?);
|
||||||
let _ = store.save();
|
|
||||||
|
if let Err(e) = store.save() {
|
||||||
|
tracing::error!("Failed to persist store changes: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Application state initialized successfully");
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
active_registration_code: RwLock::new(None),
|
active_registration_code: RwLock::new(None),
|
||||||
|
|
@ -62,7 +68,9 @@ impl AppState {
|
||||||
/// NOTE: Although DashMap API allows you to update the list from an immutable
|
/// NOTE: Although DashMap API allows you to update the list from an immutable
|
||||||
/// reference, you shouldn't do it for registrations as `update_registrations`
|
/// reference, you shouldn't do it for registrations as `update_registrations`
|
||||||
/// performs save operation that needs to be done and should be used instead
|
/// performs save operation that needs to be done and should be used instead
|
||||||
|
#[tracing::instrument]
|
||||||
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
|
pub fn get_registrations(&self) -> &DashMap<String, Registration> {
|
||||||
|
tracing::debug!("Retrieving registrations list");
|
||||||
&self.registrations
|
&self.registrations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -71,60 +79,117 @@ impl AppState {
|
||||||
/// This function bypasses `store.reload()` to avoid issues from stale or inconsistent
|
/// This function bypasses `store.reload()` to avoid issues from stale or inconsistent
|
||||||
/// data on disk. By relying solely on the in-memory `self.registrations`,
|
/// data on disk. By relying solely on the in-memory `self.registrations`,
|
||||||
/// we make sure that updates are applied based on the most recent changes in memory.
|
/// we make sure that updates are applied based on the most recent changes in memory.
|
||||||
|
#[tracing::instrument(skip(self, app_handle, update_func))]
|
||||||
pub fn update_registrations(
|
pub fn update_registrations(
|
||||||
&self,
|
&self,
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
update_func: impl FnOnce(&DashMap<String, Registration>),
|
update_func: impl FnOnce(&DashMap<String, Registration>),
|
||||||
) -> Result<(), AgentError> {
|
) -> Result<(), AgentError> {
|
||||||
|
tracing::info!("Updating registrations");
|
||||||
update_func(&self.registrations);
|
update_func(&self.registrations);
|
||||||
|
|
||||||
let store = app_handle.store(AGENT_STORE)?;
|
let store = match app_handle.store(AGENT_STORE) {
|
||||||
|
Ok(store) => store,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to access app store: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if store.has(REGISTRATIONS) {
|
if store.has(REGISTRATIONS) {
|
||||||
|
tracing::debug!("Clearing existing registrations from store");
|
||||||
// We've confirmed `REGISTRATIONS` exists in the store
|
// We've confirmed `REGISTRATIONS` exists in the store
|
||||||
store
|
if !store.delete(REGISTRATIONS) {
|
||||||
.delete(REGISTRATIONS)
|
tracing::error!("Failed to clear existing registrations");
|
||||||
.then_some(())
|
return Err(AgentError::RegistrationClearError);
|
||||||
.ok_or(AgentError::RegistrationClearError)?;
|
}
|
||||||
} else {
|
} else {
|
||||||
log::debug!("`REGISTRATIONS` key not found in store; continuing with update.");
|
tracing::debug!("`REGISTRATIONS` key not found in store; continuing with update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Since we've established `self.registrations` as the source of truth,
|
// Since we've established `self.registrations` as the source of truth,
|
||||||
// we avoid reloading the store from disk and instead choose to override it.
|
// we avoid reloading the store from disk and instead choose to override it.
|
||||||
|
match serde_json::to_value(self.registrations.clone()) {
|
||||||
store.set(
|
Ok(value) => {
|
||||||
REGISTRATIONS,
|
let _ = store.set(REGISTRATIONS, value);
|
||||||
serde_json::to_value(self.registrations.clone())?,
|
}
|
||||||
);
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to serialize registrations: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Explicitly save the changes
|
// Explicitly save the changes
|
||||||
store.save()?;
|
if let Err(e) = store.save() {
|
||||||
|
tracing::error!("Failed to persist store changes: {}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Registrations updated successfully");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all the registrations
|
/// Clear all the registrations
|
||||||
|
#[tracing::instrument(skip(self, app_handle))]
|
||||||
pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> {
|
pub fn clear_registrations(&self, app_handle: tauri::AppHandle) -> Result<(), AgentError> {
|
||||||
Ok(self.update_registrations(app_handle, |registrations| registrations.clear())?)
|
tracing::info!("Clearing all registrations");
|
||||||
|
self.update_registrations(app_handle, |registrations| registrations.clear())?;
|
||||||
|
tracing::info!("All registrations cleared successfully");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
|
pub async fn clear_active_registration(&self) {
|
||||||
|
tracing::debug!("Clearing active registration code");
|
||||||
|
let mut active_registration_code = self.active_registration_code.write().await;
|
||||||
|
*active_registration_code = None;
|
||||||
|
tracing::debug!("Active registration code cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub async fn validate_registration(&self, registration: &str) -> bool {
|
pub async fn validate_registration(&self, registration: &str) -> bool {
|
||||||
self.active_registration_code.read().await.as_deref() == Some(registration)
|
tracing::debug!("Validating registration code");
|
||||||
|
let is_valid = self.active_registration_code.read().await.as_deref() == Some(registration);
|
||||||
|
if is_valid {
|
||||||
|
tracing::info!("Registration code validated successfully");
|
||||||
|
} else {
|
||||||
|
tracing::warn!("Invalid registration code provided");
|
||||||
|
}
|
||||||
|
is_valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
|
pub fn remove_cancellation_token(&self, req_id: usize) -> Option<(usize, CancellationToken)> {
|
||||||
self.cancellation_tokens.remove(&req_id)
|
tracing::debug!(req_id, "Removing cancellation token");
|
||||||
|
let result = self.cancellation_tokens.remove(&req_id);
|
||||||
|
if result.is_some() {
|
||||||
|
tracing::info!(req_id, "Cancellation token removed successfully");
|
||||||
|
} else {
|
||||||
|
tracing::debug!(req_id, "No cancellation token found to remove");
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_cancellation_token(&self, req_id: usize, cancellation_tokens: CancellationToken) {
|
#[tracing::instrument(skip(self))]
|
||||||
self.cancellation_tokens.insert(req_id, cancellation_tokens);
|
pub fn add_cancellation_token(&self, req_id: usize, cancellation_token: CancellationToken) {
|
||||||
|
tracing::debug!(req_id, "Adding new cancellation token");
|
||||||
|
self.cancellation_tokens.insert(req_id, cancellation_token);
|
||||||
|
tracing::debug!(req_id, "Cancellation token added successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self))]
|
||||||
pub fn validate_access(&self, auth_key: &str) -> bool {
|
pub fn validate_access(&self, auth_key: &str) -> bool {
|
||||||
self.registrations.get(auth_key).is_some()
|
tracing::debug!(auth_key, "Validating access");
|
||||||
|
let is_valid = self.registrations.get(auth_key).is_some();
|
||||||
|
if is_valid {
|
||||||
|
tracing::info!(auth_key, "Access validated successfully");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(auth_key, "Invalid access attempt");
|
||||||
|
}
|
||||||
|
is_valid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(self, data))]
|
||||||
pub fn validate_access_and_get_data<T>(
|
pub fn validate_access_and_get_data<T>(
|
||||||
&self,
|
&self,
|
||||||
auth_key: &str,
|
auth_key: &str,
|
||||||
|
|
@ -134,28 +199,79 @@ impl AppState {
|
||||||
where
|
where
|
||||||
T: DeserializeOwned,
|
T: DeserializeOwned,
|
||||||
{
|
{
|
||||||
if let Some(registration) = self.registrations.get(auth_key) {
|
tracing::debug!(
|
||||||
let key: [u8; 32] = base16::decode(®istration.shared_secret_b16).ok()?[0..32]
|
auth_key,
|
||||||
.try_into()
|
nonce_len = nonce.len(),
|
||||||
.ok()?;
|
"Validating access and decrypting data"
|
||||||
|
);
|
||||||
|
|
||||||
let nonce: [u8; 12] = base16::decode(nonce).ok()?[0..12].try_into().ok()?;
|
let registration = match self.registrations.get(auth_key) {
|
||||||
|
Some(reg) => reg,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(auth_key, "Registration not found");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let cipher = Aes256Gcm::new(&key.into());
|
let key: [u8; 32] = match base16::decode(®istration.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 data = data.iter().cloned().collect::<Vec<u8>>();
|
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 plain_data = cipher.decrypt(&nonce.into(), data.as_slice()).ok()?;
|
let cipher = Aes256Gcm::new(&key.into());
|
||||||
|
let data = data.iter().cloned().collect::<Vec<u8>>();
|
||||||
|
|
||||||
serde_json::from_reader(plain_data.as_slice()).ok()
|
let plain_data = match cipher.decrypt(&nonce.into(), data.as_slice()) {
|
||||||
} else {
|
Ok(d) => d,
|
||||||
None
|
Err(e) => {
|
||||||
|
tracing::error!(auth_key, error = ?e, "Decryption failed");
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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> {
|
#[tracing::instrument(skip(self))]
|
||||||
self.registrations
|
pub fn get_registration(&self, auth_key: &str) -> Option<Registration> {
|
||||||
|
tracing::debug!(auth_key, "Retrieving registration tracing::info");
|
||||||
|
let result = self
|
||||||
|
.registrations
|
||||||
.get(auth_key)
|
.get(auth_key)
|
||||||
.map(|reference| reference.value().clone())
|
.map(|reference| reference.value().clone());
|
||||||
|
|
||||||
|
if result.is_some() {
|
||||||
|
tracing::info!(
|
||||||
|
auth_key,
|
||||||
|
"Registration tracing::info retrieved successfully"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tracing::debug!(auth_key, "No registration tracing::info found");
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use crate::state::AppState;
|
use crate::{show_main_window, state::AppState};
|
||||||
use lazy_static::lazy_static;
|
use lazy_static::lazy_static;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{
|
use tauri::{
|
||||||
image::Image,
|
image::Image,
|
||||||
menu::{MenuBuilder, MenuItem},
|
menu::{MenuBuilder, MenuItem},
|
||||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
AppHandle, Manager,
|
AppHandle, Emitter, Manager,
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
|
const TRAY_ICON_DATA: &'static [u8] = include_bytes!("../icons/tray_icon.png");
|
||||||
|
|
@ -23,6 +23,13 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
true,
|
true,
|
||||||
None::<&str>,
|
None::<&str>,
|
||||||
)?;
|
)?;
|
||||||
|
let show_registrations = MenuItem::with_id(
|
||||||
|
app,
|
||||||
|
"show_registrations",
|
||||||
|
"Show Registrations",
|
||||||
|
true,
|
||||||
|
None::<&str>,
|
||||||
|
)?;
|
||||||
|
|
||||||
let pkg_info = app.package_info();
|
let pkg_info = app.package_info();
|
||||||
let app_name = pkg_info.name.clone();
|
let app_name = pkg_info.name.clone();
|
||||||
|
|
@ -42,6 +49,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
.item(&app_version_item)
|
.item(&app_version_item)
|
||||||
.separator()
|
.separator()
|
||||||
.item(&clear_registrations)
|
.item(&clear_registrations)
|
||||||
|
.item(&show_registrations)
|
||||||
|
.separator()
|
||||||
.item(&quit_i)
|
.item(&quit_i)
|
||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
|
|
@ -57,8 +66,9 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
.menu_on_left_click(true)
|
.menu_on_left_click(true)
|
||||||
.on_menu_event(move |app, event| match event.id.as_ref() {
|
.on_menu_event(move |app, event| match event.id.as_ref() {
|
||||||
"quit" => {
|
"quit" => {
|
||||||
log::info!("Exiting the agent...");
|
tracing::info!("Exiting the agent...");
|
||||||
app.exit(-1);
|
// Exit with a specific code to allow actual exit.
|
||||||
|
app.exit(1);
|
||||||
}
|
}
|
||||||
"clear_registrations" => {
|
"clear_registrations" => {
|
||||||
let app_state = app.state::<Arc<AppState>>();
|
let app_state = app.state::<Arc<AppState>>();
|
||||||
|
|
@ -67,8 +77,16 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
.clear_registrations(app.clone())
|
.clear_registrations(app.clone())
|
||||||
.expect("Invariant violation: Failed to clear registrations");
|
.expect("Invariant violation: Failed to clear registrations");
|
||||||
}
|
}
|
||||||
|
"show_registrations" => {
|
||||||
|
app.emit("show-registrations", ()).unwrap_or_else(|e| {
|
||||||
|
tracing::error!("Failed to emit show-registrations event: {}", e);
|
||||||
|
});
|
||||||
|
if let Err(e) = show_main_window(&app) {
|
||||||
|
tracing::error!("Failed to show window: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
log::warn!("Unhandled menu event: {:?}", event.id);
|
tracing::warn!("Unhandled menu event: {:?}", event.id);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_tray_icon_event(|tray, event| {
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
|
@ -79,9 +97,8 @@ pub fn create_tray(app: &AppHandle) -> tauri::Result<()> {
|
||||||
} = event
|
} = event
|
||||||
{
|
{
|
||||||
let app = tray.app_handle();
|
let app = tray.app_handle();
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Err(e) = show_main_window(&app) {
|
||||||
let _ = window.show();
|
tracing::error!("Failed to show window from tray: {}", e);
|
||||||
let _ = window.set_focus();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ use axum::{
|
||||||
};
|
};
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
use crate::global::NONCE;
|
||||||
|
|
||||||
|
pub fn generate_auth_key_hash(auth_key: &str) -> String {
|
||||||
|
let hash = Sha256::digest(auth_key.as_bytes());
|
||||||
|
base16::encode_lower(&hash[..3])
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open_link(link: &str) -> Option<()> {
|
pub fn open_link(link: &str) -> Option<()> {
|
||||||
let null = Stdio::null();
|
let null = Stdio::null();
|
||||||
|
|
@ -79,7 +87,7 @@ where
|
||||||
let response_headers = response.headers_mut();
|
let response_headers = response.headers_mut();
|
||||||
|
|
||||||
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
|
response_headers.insert("Content-Type", "application/octet-stream".parse().unwrap());
|
||||||
response_headers.insert("X-Hopp-Nonce", nonce_b16.parse().unwrap());
|
response_headers.insert(NONCE, nonce_b16.parse().unwrap());
|
||||||
|
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ pub fn init_webview() {
|
||||||
)
|
)
|
||||||
.not()
|
.not()
|
||||||
{
|
{
|
||||||
log::warn!("Declined to setup WebView.");
|
tracing::warn!("Declined to setup WebView.");
|
||||||
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +196,7 @@ pub fn init_webview() {
|
||||||
));
|
));
|
||||||
|
|
||||||
if let Err(e) = open_install_website() {
|
if let Err(e) = open_install_website() {
|
||||||
log::warn!("Failed to launch WebView website:\n{}", e);
|
tracing::warn!("Failed to launch WebView website:\n{}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,179 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="font-sans min-h-screen flex flex-col">
|
<div class="h-screen p-5 flex flex-col gap-y-2">
|
||||||
<div class="p-5 flex flex-col flex-grow gap-y-2">
|
<h1 class="font-bold text-lg text-white">{{ pipe(state(), getTitle) }}</h1>
|
||||||
<h1 class="font-bold text-lg text-white">Agent Registration Request</h1>
|
|
||||||
<p class="tracking-wide">
|
<template v-if="isOtpView(state())">
|
||||||
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
|
<div v-if="state().otp" class="flex-grow">
|
||||||
the app to complete the registration process. Please close the window if you did not initiate this request.
|
<p class="tracking-wide">
|
||||||
Do not close this window until the verification code is entered. Once done, this window will close by itself.
|
An app is trying to register against the Hoppscotch Agent. If this was intentional, copy the given code into
|
||||||
</p>
|
the app to complete the registration process. Please hide the window if you did not initiate this request.
|
||||||
<p class="font-bold text-5xl tracking-wider text-center pt-10 text-white">
|
Do not hide this window until the verification code is entered. The window will hide automatically once done.
|
||||||
{{ otpCode }}
|
</p>
|
||||||
</p>
|
<p
|
||||||
</div>
|
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">
|
<div class="border-t border-divider p-5 flex justify-between">
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
|
v-if="shouldShowCopy(state())"
|
||||||
label="Copy Code"
|
label="Copy Code"
|
||||||
outline
|
outline
|
||||||
filled
|
filled
|
||||||
:icon="copyIcon"
|
:icon="copyIcon"
|
||||||
@click="copyCode"
|
@click="copyOtp"
|
||||||
/>
|
|
||||||
<HoppButtonPrimary
|
|
||||||
label="Close"
|
|
||||||
outline
|
|
||||||
@click="closeWindow"
|
|
||||||
/>
|
/>
|
||||||
|
<HoppButtonPrimary label="Hide Window" outline @click="hideWindow" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, markRaw, onMounted } from "vue"
|
import { ref, markRaw, onMounted } from "vue"
|
||||||
import { HoppButtonPrimary, HoppButtonSecondary } from "@hoppscotch/ui"
|
import {
|
||||||
|
HoppButtonPrimary,
|
||||||
|
HoppButtonSecondary,
|
||||||
|
HoppSmartTable,
|
||||||
|
} from "@hoppscotch/ui"
|
||||||
import IconCopy from "~icons/lucide/copy"
|
import IconCopy from "~icons/lucide/copy"
|
||||||
import IconCheck from "~icons/lucide/check"
|
import IconCheck from "~icons/lucide/check"
|
||||||
import { useClipboard, refAutoReset } from "@vueuse/core"
|
import { useClipboard, refAutoReset } from "@vueuse/core"
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window"
|
import { getCurrentWindow } from "@tauri-apps/api/window"
|
||||||
import { invoke } from "@tauri-apps/api/core"
|
import { invoke } from "@tauri-apps/api/core"
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from "@tauri-apps/api/event"
|
||||||
|
import { pipe } from "fp-ts/function"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
|
import * as TE from "fp-ts/TaskEither"
|
||||||
|
import { orderBy } from "lodash-es"
|
||||||
|
|
||||||
const { copy } = useClipboard()
|
interface Registration {
|
||||||
const otpCode = ref("")
|
auth_key_hash: string
|
||||||
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
|
registered_at: string
|
||||||
|
|
||||||
function copyCode() {
|
|
||||||
copyIcon.value = markRaw(IconCheck)
|
|
||||||
copy(otpCode.value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeWindow() {
|
interface AppState {
|
||||||
const currentWindow = getCurrentWindow()
|
view: "otp" | "registrations"
|
||||||
currentWindow.close()
|
otp: O.Option<string>
|
||||||
|
registrations: Registration[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABLE_HEADINGS = [
|
||||||
|
{ key: "auth_key_hash", label: "ID" },
|
||||||
|
{ key: "registered_at", label: "Registered At" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const { copy } = useClipboard()
|
||||||
|
const copyIcon = refAutoReset(markRaw(IconCopy), 3000)
|
||||||
|
const appState = ref<AppState>({
|
||||||
|
view: "otp",
|
||||||
|
otp: O.none,
|
||||||
|
registrations: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = () => appState.value
|
||||||
|
|
||||||
|
const isOtpView = (s: AppState): boolean => s.view === "otp"
|
||||||
|
const getTitle = (s: AppState): string =>
|
||||||
|
s.view === "otp" ? "Agent Registration Request" : "Agent Registrations"
|
||||||
|
const shouldShowCopy = (s: AppState): boolean => isOtpView(s) && O.isSome(s.otp)
|
||||||
|
const formatDate = (date: string): string => new Date(date).toLocaleString()
|
||||||
|
|
||||||
|
const getOtp = TE.tryCatch(
|
||||||
|
() => invoke<string>("get_otp", {}),
|
||||||
|
(error: unknown) => new Error(String(error))
|
||||||
|
)
|
||||||
|
|
||||||
|
const listRegistrations = TE.tryCatch(
|
||||||
|
async () => {
|
||||||
|
const result = await invoke<{ registrations: Registration[] }>(
|
||||||
|
"list_registrations",
|
||||||
|
{}
|
||||||
|
)
|
||||||
|
return orderBy(result.registrations, "registered_at", "desc")
|
||||||
|
},
|
||||||
|
(error: unknown) => new Error(String(error))
|
||||||
|
)
|
||||||
|
|
||||||
|
const hideWindow = () => {
|
||||||
|
getCurrentWindow().hide()
|
||||||
|
appState.value = { ...state(), otp: O.none }
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyOtp = () => {
|
||||||
|
pipe(
|
||||||
|
state().otp,
|
||||||
|
O.map((otp: string) => {
|
||||||
|
copyIcon.value = markRaw(IconCheck)
|
||||||
|
copy(otp)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRegistrations = async () => {
|
||||||
|
await pipe(
|
||||||
|
listRegistrations,
|
||||||
|
TE.map((registrations: Registration) => {
|
||||||
|
appState.value = { ...state(), registrations }
|
||||||
|
})
|
||||||
|
)()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRegistrationReceived = (payload: string) => {
|
||||||
|
appState.value = {
|
||||||
|
...state(),
|
||||||
|
view: "otp",
|
||||||
|
otp: O.some(payload),
|
||||||
|
}
|
||||||
|
getCurrentWindow().setFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAuthenticated = async () => {
|
||||||
|
appState.value = { ...state(), otp: O.none }
|
||||||
|
await updateRegistrations()
|
||||||
|
hideWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleShowRegistrations = async () => {
|
||||||
|
appState.value = { ...state(), view: "registrations" }
|
||||||
|
await updateRegistrations()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const currentWindow = getCurrentWindow()
|
getCurrentWindow().setAlwaysOnTop(true)
|
||||||
|
|
||||||
currentWindow.setFocus(true);
|
await pipe(
|
||||||
currentWindow.setAlwaysOnTop(true);
|
getOtp,
|
||||||
|
TE.map((otp: string) => {
|
||||||
|
if (otp) appState.value = { ...state(), otp: O.some(otp) }
|
||||||
|
})
|
||||||
|
)()
|
||||||
|
|
||||||
otpCode.value = await invoke("get_otp", {})
|
await Promise.all([
|
||||||
|
listen<string>(
|
||||||
await listen('registration_received', (event) => {
|
"registration-received",
|
||||||
otpCode.value = event.payload
|
({ payload }: { payload: string }) => handleRegistrationReceived(payload)
|
||||||
})
|
),
|
||||||
|
listen(
|
||||||
await listen('authenticated', () => {
|
"window-hidden",
|
||||||
closeWindow()
|
() => (appState.value = { ...state(), otp: O.none })
|
||||||
})
|
),
|
||||||
|
listen("authenticated", handleAuthenticated),
|
||||||
|
listen("show-registrations", handleShowRegistrations),
|
||||||
|
])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
80
packages/hoppscotch-agent/src/pages/otp.vue
Normal file
80
packages/hoppscotch-agent/src/pages/otp.vue
Normal 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>
|
||||||
57
packages/hoppscotch-agent/src/pages/registrations.vue
Normal file
57
packages/hoppscotch-agent/src/pages/registrations.vue
Normal 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>
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ForbiddenException, HttpException, Module } from '@nestjs/common';
|
import { HttpException, Module } from '@nestjs/common';
|
||||||
import { GraphQLModule } from '@nestjs/graphql';
|
import { GraphQLModule } from '@nestjs/graphql';
|
||||||
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
|
||||||
import { UserModule } from './user/user.module';
|
import { UserModule } from './user/user.module';
|
||||||
|
|
@ -8,7 +8,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||||
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
|
import { UserEnvironmentsModule } from './user-environment/user-environments.module';
|
||||||
import { UserRequestModule } from './user-request/user-request.module';
|
import { UserRequestModule } from './user-request/user-request.module';
|
||||||
import { UserHistoryModule } from './user-history/user-history.module';
|
import { UserHistoryModule } from './user-history/user-history.module';
|
||||||
import { subscriptionContextCookieParser } from './auth/helper';
|
import { subscriptionContextCookieParser, extractAccessTokenFromAuthRecords } from './auth/helper';
|
||||||
import { TeamModule } from './team/team.module';
|
import { TeamModule } from './team/team.module';
|
||||||
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
import { TeamEnvironmentsModule } from './team-environments/team-environments.module';
|
||||||
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
import { TeamCollectionModule } from './team-collection/team-collection.module';
|
||||||
|
|
@ -52,20 +52,29 @@ import { InfraTokenModule } from './infra-token/infra-token.module';
|
||||||
subscriptions: {
|
subscriptions: {
|
||||||
'subscriptions-transport-ws': {
|
'subscriptions-transport-ws': {
|
||||||
path: '/graphql',
|
path: '/graphql',
|
||||||
onConnect: (_, websocket) => {
|
onConnect: (connectionParams, websocket) => {
|
||||||
|
const websocketHeaders = websocket?.upgradeReq?.headers;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cookies = subscriptionContextCookieParser(
|
const accessToken = extractAccessTokenFromAuthRecords(connectionParams);
|
||||||
websocket.upgradeReq.headers.cookie,
|
const authorization = `Bearer ${accessToken}`
|
||||||
);
|
|
||||||
return {
|
return { headers: { ...websocketHeaders, authorization } };
|
||||||
headers: { ...websocket?.upgradeReq?.headers, cookies },
|
} catch (authError) {
|
||||||
};
|
const cookiesFromHeader = websocketHeaders?.cookie;
|
||||||
} catch (error) {
|
const cookies = cookiesFromHeader
|
||||||
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
? subscriptionContextCookieParser(cookiesFromHeader)
|
||||||
cause: new Error(COOKIES_NOT_FOUND),
|
: null;
|
||||||
});
|
|
||||||
|
if (!cookies) {
|
||||||
|
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(COOKIES_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers: { ...websocketHeaders, cookies } };
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
context: ({ req, res, connection }) => ({
|
context: ({ req, res, connection }) => ({
|
||||||
|
|
|
||||||
|
|
@ -193,4 +193,24 @@ export class AuthController {
|
||||||
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
|
if (E.isLeft(userInfo)) throwHTTPErr(userInfo.left);
|
||||||
return userInfo.right;
|
return userInfo.right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('desktop')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@UseInterceptors(UserLastLoginInterceptor)
|
||||||
|
async desktopAuthCallback(
|
||||||
|
@GqlUser() user: AuthUser,
|
||||||
|
@Query('redirect_uri') redirectUri: string,
|
||||||
|
) {
|
||||||
|
if (!redirectUri || !redirectUri.startsWith('http://localhost')) {
|
||||||
|
throwHTTPErr({
|
||||||
|
message: 'Invalid desktop callback URL',
|
||||||
|
statusCode: 400
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await this.authService.generateAuthTokens(user.uid);
|
||||||
|
if (E.isLeft(tokens)) throwHTTPErr(tokens.left);
|
||||||
|
|
||||||
|
return tokens.right;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@ import { DateTime } from 'luxon';
|
||||||
import { AuthTokens } from 'src/types/AuthTokens';
|
import { AuthTokens } from 'src/types/AuthTokens';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import * as cookie from 'cookie';
|
import * as cookie from 'cookie';
|
||||||
import { AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND } from 'src/errors';
|
import { AUTH_HEADER_NOT_FOUND, AUTH_PROVIDER_NOT_SPECIFIED, COOKIES_NOT_FOUND, INVALID_AUTH_HEADER } from 'src/errors';
|
||||||
import { throwErr } from 'src/utils';
|
import { throwErr } from 'src/utils';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { IncomingHttpHeaders } from 'http';
|
||||||
|
|
||||||
enum AuthTokenType {
|
enum AuthTokenType {
|
||||||
ACCESS_TOKEN = 'access_token',
|
ACCESS_TOKEN = 'access_token',
|
||||||
|
|
@ -117,11 +118,102 @@ export function authProviderCheck(
|
||||||
|
|
||||||
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
const envVariables = VITE_ALLOWED_AUTH_PROVIDERS
|
||||||
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
? VITE_ALLOWED_AUTH_PROVIDERS.split(',').map((provider) =>
|
||||||
provider.trim().toUpperCase(),
|
provider.trim().toUpperCase(),
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (!envVariables.includes(provider.toUpperCase())) return false;
|
if (!envVariables.includes(provider.toUpperCase())) return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract cookie as key-value pairs from headers of a request
|
||||||
|
* @param headers HTTP request headers containing auth tokens
|
||||||
|
* @returns Cookie's key-value pairs
|
||||||
|
*/
|
||||||
|
export const extractCookieAsKeyValuesFromHeaders = (headers: IncomingHttpHeaders) => {
|
||||||
|
const cookieHeader = headers['cookie'] || headers['Cookie'] || headers['COOKIE'];
|
||||||
|
|
||||||
|
if (!cookieHeader) {
|
||||||
|
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(COOKIES_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStr = Array.isArray(cookieHeader) ? cookieHeader[0] : cookieHeader;
|
||||||
|
|
||||||
|
const kv = cookieStr.split(';')
|
||||||
|
.map(cookie => cookie.trim())
|
||||||
|
.reduce((acc, curr) => {
|
||||||
|
const [key, value] = curr.split('=');
|
||||||
|
acc[key] = value;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>);
|
||||||
|
|
||||||
|
return kv;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract auth tokens from cookie headers of a request
|
||||||
|
* @param headers HTTP request headers containing auth tokens
|
||||||
|
* @returns AuthTokens for JWT strategy to use
|
||||||
|
*/
|
||||||
|
export const extractAuthTokensFromCookieHeaders = (headers: IncomingHttpHeaders) => {
|
||||||
|
const cookieKV = extractCookieAsKeyValuesFromHeaders(headers);
|
||||||
|
|
||||||
|
if (!cookieKV[AuthTokenType.ACCESS_TOKEN] || !cookieKV[AuthTokenType.REFRESH_TOKEN]) {
|
||||||
|
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(COOKIES_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AuthTokens>{
|
||||||
|
access_token: cookieKV[AuthTokenType.ACCESS_TOKEN],
|
||||||
|
refresh_token: cookieKV[AuthTokenType.REFRESH_TOKEN],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract access tokens from cookie headers of a request
|
||||||
|
* @param headers HTTP request headers containing access tokens
|
||||||
|
* @returns AccessTokens for JWT strategy to use
|
||||||
|
*/
|
||||||
|
export const extractAccessTokensFromCookieHeaders = (headers: IncomingHttpHeaders) => {
|
||||||
|
const cookieKV = extractCookieAsKeyValuesFromHeaders(headers);
|
||||||
|
|
||||||
|
if (!cookieKV[AuthTokenType.ACCESS_TOKEN]) {
|
||||||
|
throw new HttpException(COOKIES_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(COOKIES_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access_token: cookieKV[AuthTokenType.ACCESS_TOKEN],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract access token from authorization header
|
||||||
|
* @param headers HTTP request headers containing bearer token
|
||||||
|
* @returns AccessTokens for JWT strategy
|
||||||
|
*/
|
||||||
|
export const extractAccessTokenFromAuthRecords = (headers: IncomingHttpHeaders) => {
|
||||||
|
const authHeader = headers['authorization'] || headers['Authorization'];
|
||||||
|
if (!authHeader) {
|
||||||
|
throw new HttpException(AUTH_HEADER_NOT_FOUND, 400, {
|
||||||
|
cause: new Error(AUTH_HEADER_NOT_FOUND),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader;
|
||||||
|
const [bearer, access_token] = headerValue.split(' ');
|
||||||
|
|
||||||
|
if (bearer !== 'Bearer' || !access_token) {
|
||||||
|
throw new HttpException(INVALID_AUTH_HEADER, 400, {
|
||||||
|
cause: new Error(INVALID_AUTH_HEADER),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return access_token;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,66 @@
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import {
|
import { Injectable, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
ForbiddenException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
import { AccessTokenPayload } from 'src/types/AuthTokens';
|
||||||
import { UserService } from 'src/user/user.service';
|
import { UserService } from 'src/user/user.service';
|
||||||
import { AuthService } from '../auth.service';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Request } from 'express';
|
import { Request } from 'express';
|
||||||
import * as O from 'fp-ts/Option';
|
import * as O from 'fp-ts/Option';
|
||||||
import {
|
import * as E from 'fp-ts/Either';
|
||||||
COOKIES_NOT_FOUND,
|
import { pipe } from 'fp-ts/function';
|
||||||
INVALID_ACCESS_TOKEN,
|
import { COOKIES_NOT_FOUND, INVALID_ACCESS_TOKEN, USER_NOT_FOUND } from 'src/errors';
|
||||||
USER_NOT_FOUND,
|
|
||||||
} from 'src/errors';
|
/**
|
||||||
import { ConfigService } from '@nestjs/config';
|
* Extracts an access token from a cookie in the request.
|
||||||
|
*
|
||||||
|
* @param request - Express Request object
|
||||||
|
* @returns Option<string> containing the token if found
|
||||||
|
*/
|
||||||
|
const extractFromCookie = (request: Request): O.Option<string> =>
|
||||||
|
pipe(
|
||||||
|
O.fromNullable(request.cookies),
|
||||||
|
O.chain(cookies => O.fromNullable(cookies['access_token']))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts an access token from the Authorization header.
|
||||||
|
* Expects the header to be in the format: 'Bearer <token>'.
|
||||||
|
*
|
||||||
|
* @param request - Express Request object
|
||||||
|
* @returns Option<string> containing the token if found
|
||||||
|
*/
|
||||||
|
const extractFromAuthHeaders = (request: Request): O.Option<string> =>
|
||||||
|
pipe(
|
||||||
|
// First try headers.authorization, then fall back to root level authorization
|
||||||
|
// see `gql-auth.guard` for more info.
|
||||||
|
O.fromNullable(
|
||||||
|
request?.headers?.authorization ||
|
||||||
|
(request && 'authorization' in request ? request['authorization'] : undefined)
|
||||||
|
),
|
||||||
|
O.chain(auth =>
|
||||||
|
typeof auth === 'string' && auth.startsWith('Bearer ')
|
||||||
|
? O.some(auth.slice(7))
|
||||||
|
: O.none
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines cookie and header token extraction strategies.
|
||||||
|
* Attempts to extract from cookie first, then falls back to Authorization header.
|
||||||
|
*
|
||||||
|
* @param request - Express Request object
|
||||||
|
* @returns Either<Error, string> containing the token or an error
|
||||||
|
*/
|
||||||
|
const extractToken = (request: Request): E.Either<Error, string> =>
|
||||||
|
pipe(
|
||||||
|
extractFromCookie(request),
|
||||||
|
O.alt(() => extractFromAuthHeaders(request)),
|
||||||
|
// Neither `Authorization` header nor `Cookie` were found with the request,
|
||||||
|
// `COOKIES_NOT_FOUND` for backwards compatibility.
|
||||||
|
E.fromOption(() => {
|
||||||
|
return new ForbiddenException(COOKIES_NOT_FOUND);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
|
|
@ -25,13 +70,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
(request: Request) => {
|
(request: Request) =>
|
||||||
const ATCookie = request.cookies['access_token'];
|
pipe(
|
||||||
if (!ATCookie) {
|
extractToken(request),
|
||||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
E.fold(
|
||||||
}
|
error => { throw error; },
|
||||||
return ATCookie;
|
token => { return token }
|
||||||
},
|
)
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
secretOrKey: configService.get('JWT_SECRET'),
|
secretOrKey: configService.get('JWT_SECRET'),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export class RTJwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
|
||||||
(request: Request) => {
|
(request: Request) => {
|
||||||
const RTCookie = request.cookies['refresh_token'];
|
const RTCookie = request.cookies['refresh_token'];
|
||||||
if (!RTCookie) {
|
if (!RTCookie) {
|
||||||
|
console.error("`refresh_token` not found")
|
||||||
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
throw new ForbiddenException(COOKIES_NOT_FOUND);
|
||||||
}
|
}
|
||||||
return RTCookie;
|
return RTCookie;
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,18 @@ export const TOKEN_EXPIRED = 'auth/token_expired' as const;
|
||||||
*/
|
*/
|
||||||
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
|
export const MAGIC_LINK_EXPIRED = 'auth/magic_link_expired' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth header was NOT found in the auth request
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const AUTH_HEADER_NOT_FOUND = 'auth/auth_header_not_found' as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auth header was found but the format was invalid
|
||||||
|
* (AuthService)
|
||||||
|
*/
|
||||||
|
export const INVALID_AUTH_HEADER = 'auth/invalid_auth_header' as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No cookies were found in the auth request
|
* No cookies were found in the auth request
|
||||||
* (AuthService)
|
* (AuthService)
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@
|
||||||
"select_workspace": "Select a workspace",
|
"select_workspace": "Select a workspace",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"clear_all": "Clear all",
|
"clear_all": "Clear all",
|
||||||
|
"clear_cache": "Clear Cache",
|
||||||
"clear_history": "Clear all History",
|
"clear_history": "Clear all History",
|
||||||
|
"clear_unpinned": "Clear Unpinned",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"confirm": "Confirm",
|
||||||
"connect": "Connect",
|
"connect": "Connect",
|
||||||
"connecting": "Connecting",
|
"connecting": "Connecting",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
|
@ -18,6 +21,7 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"disconnect": "Disconnect",
|
"disconnect": "Disconnect",
|
||||||
"dismiss": "Dismiss",
|
"dismiss": "Dismiss",
|
||||||
|
"done": "Done",
|
||||||
"dont_save": "Don't save",
|
"dont_save": "Don't save",
|
||||||
"download_file": "Download file",
|
"download_file": "Download file",
|
||||||
"download_test_report": "Download test report",
|
"download_test_report": "Download test report",
|
||||||
|
|
@ -42,6 +46,7 @@
|
||||||
"properties": "Properties",
|
"properties": "Properties",
|
||||||
"register": "Register",
|
"register": "Register",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"remove_instance": "Remove instance",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
"restore": "Restore",
|
"restore": "Restore",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
|
|
@ -96,7 +101,6 @@
|
||||||
"enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration",
|
"enter_otp_instruction": "Please enter the verification code generated by Hoppscotch Agent and complete the registration",
|
||||||
"otp_label": "Verification Code",
|
"otp_label": "Verification Code",
|
||||||
"processing": "Processing your request...",
|
"processing": "Processing your request...",
|
||||||
"not_running": "The Hoppscotch Agent is not running. Please start the agent and click 'Retry'.",
|
|
||||||
"not_running_title": "Agent not detected",
|
"not_running_title": "Agent not detected",
|
||||||
"registration_title": "Agent registration",
|
"registration_title": "Agent registration",
|
||||||
"verify_ssl_certs": "Verify SSL Certificates",
|
"verify_ssl_certs": "Verify SSL Certificates",
|
||||||
|
|
@ -255,8 +259,7 @@
|
||||||
"nonce_count": "Nonce Count",
|
"nonce_count": "Nonce Count",
|
||||||
"client_nonce": "Client Nonce",
|
"client_nonce": "Client Nonce",
|
||||||
"opaque": "Opaque",
|
"opaque": "Opaque",
|
||||||
"disable_retry": "Disable Retrying Request",
|
"disable_retry": "Disable Retrying Request"
|
||||||
"inspector_warning": "Agent interceptor is recommended when using Digest Authorization."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"collection": {
|
"collection": {
|
||||||
|
|
@ -416,6 +419,43 @@
|
||||||
"details": "Details"
|
"details": "Details"
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
|
"network": {
|
||||||
|
"heading": "Network Error",
|
||||||
|
"description": "Network connection failed. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"timeout": {
|
||||||
|
"heading": "Timeout Error",
|
||||||
|
"description": "Request timed out during {phase}. {message}"
|
||||||
|
},
|
||||||
|
"certificate": {
|
||||||
|
"heading": "Certificate Error",
|
||||||
|
"description": "Invalid certificate. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"heading": "Authentication Error",
|
||||||
|
"description": "Access denied. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"heading": "Proxy Error",
|
||||||
|
"description": "Proxy connection failed. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"parse": {
|
||||||
|
"heading": "Parse Error",
|
||||||
|
"description": "Failed to parse response. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"heading": "Version Error",
|
||||||
|
"description": "Incompatible versions. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"heading": "Request Aborted",
|
||||||
|
"description": "Operation cancelled. {message}: {cause}"
|
||||||
|
},
|
||||||
|
"unknown": {
|
||||||
|
"heading": "Unknown Error",
|
||||||
|
"description": "An unknown error occurred.",
|
||||||
|
"cause": "Unknown cause"
|
||||||
|
},
|
||||||
"authproviders_load_error": "Unable to load auth providers",
|
"authproviders_load_error": "Unable to load auth providers",
|
||||||
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
|
"browser_support_sse": "This browser doesn't seems to have Server Sent Events support.",
|
||||||
"check_console_details": "Check console log for details.",
|
"check_console_details": "Check console log for details.",
|
||||||
|
|
@ -536,7 +576,8 @@
|
||||||
"collection": "Collapse Collection Panel",
|
"collection": "Collapse Collection Panel",
|
||||||
"more": "Hide more",
|
"more": "Hide more",
|
||||||
"preview": "Hide Preview",
|
"preview": "Hide Preview",
|
||||||
"sidebar": "Collapse sidebar"
|
"sidebar": "Collapse sidebar",
|
||||||
|
"password": "Hide Password"
|
||||||
},
|
},
|
||||||
"import": {
|
"import": {
|
||||||
"collections": "Import collections",
|
"collections": "Import collections",
|
||||||
|
|
@ -591,6 +632,17 @@
|
||||||
"import_summary_test_scripts_title": "Test scripts",
|
"import_summary_test_scripts_title": "Test scripts",
|
||||||
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now."
|
"import_summary_not_supported_by_hoppscotch_import": "We do not support importing {featureLabel} from this source right now."
|
||||||
},
|
},
|
||||||
|
"instances": {
|
||||||
|
"switch": "Switch Hoppscotch Instance",
|
||||||
|
"enter_server_url": "Connect to a self-hosted instance",
|
||||||
|
"already_connected": "You are already connected to this instance",
|
||||||
|
"recent_connections": "Recent Connections",
|
||||||
|
"add_instance": "Add an instance",
|
||||||
|
"add_new": "Add a new instance",
|
||||||
|
"confirm_remove": "Confirm Removal",
|
||||||
|
"remove_warning": "Are you sure you want to remove this instance?",
|
||||||
|
"clear_cached_bundles": "Clear cached bundles"
|
||||||
|
},
|
||||||
"inspections": {
|
"inspections": {
|
||||||
"description": "Inspect possible errors",
|
"description": "Inspect possible errors",
|
||||||
"environment": {
|
"environment": {
|
||||||
|
|
@ -614,10 +666,36 @@
|
||||||
"extension_not_installed": "Extension not installed.",
|
"extension_not_installed": "Extension not installed.",
|
||||||
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
|
"extension_unknown_origin": "Make sure you've added the API endpoint's origin to the Hoppscotch Browser Extension list.",
|
||||||
"extention_enable_action": "Enable Browser Extension",
|
"extention_enable_action": "Enable Browser Extension",
|
||||||
"extention_not_enabled": "Extension not enabled."
|
"extention_not_enabled": "Extension not enabled.",
|
||||||
|
"localaccess_unsupported": "Current interceptor does not support local access, please consider using Agent, Extension interceptors or the Desktop App"
|
||||||
},
|
},
|
||||||
"requestBody": {
|
"auth": {
|
||||||
"active_interceptor_doesnt_support_binary_body": "Sending binary data via the current interceptor is not supported yet."
|
"digest": "Agent interceptor or the Hoppscotch Desktop app are recommended when using Digest Authorization."
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"binary": "Sending binary data via the current interceptor is not supported yet."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"interceptor": {
|
||||||
|
"native": {
|
||||||
|
"name": "Native",
|
||||||
|
"settings_title": "Native"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"name": "Agent",
|
||||||
|
"settings_title": "Agent"
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"name": "Proxy",
|
||||||
|
"settings_title": "Proxy"
|
||||||
|
},
|
||||||
|
"browser": {
|
||||||
|
"name": "Browser",
|
||||||
|
"settings_title": "Browser"
|
||||||
|
},
|
||||||
|
"extension": {
|
||||||
|
"name": "Extension",
|
||||||
|
"settings_title": "Extension"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"layout": {
|
"layout": {
|
||||||
|
|
@ -797,6 +875,13 @@
|
||||||
"account_email_description": "Your primary email address.",
|
"account_email_description": "Your primary email address.",
|
||||||
"account_name_description": "This is your display name.",
|
"account_name_description": "This is your display name.",
|
||||||
"additional": "Additional Settings",
|
"additional": "Additional Settings",
|
||||||
|
"agent_not_running": "Hoppscotch Agent not detected - click `Retry` to check again.",
|
||||||
|
"agent_not_running_short": "Check Agent's status.",
|
||||||
|
"agent_running": "Hoppscotch Agent is live.",
|
||||||
|
"agent_running_short": "Hoppscotch Agent is live.",
|
||||||
|
"agent_reset_registration": "Reset Registration",
|
||||||
|
"agent_registered": "Agent Registered",
|
||||||
|
"agent_registration_successful": "Agent Registered Successfully",
|
||||||
"auto_encode_mode": "Auto",
|
"auto_encode_mode": "Auto",
|
||||||
"auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present",
|
"auto_encode_mode_tooltip": "Encode the parameters in the request only if some special characters are present",
|
||||||
"background": "Background",
|
"background": "Background",
|
||||||
|
|
@ -807,6 +892,7 @@
|
||||||
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
|
"delete_account_description": "Once you delete your account, all your data will be permanently deleted. This action cannot be undone.",
|
||||||
"disable_encode_mode_tooltip": "Never encode the parameters in the request",
|
"disable_encode_mode_tooltip": "Never encode the parameters in the request",
|
||||||
"enable_encode_mode_tooltip": "Always encode the parameters in the request",
|
"enable_encode_mode_tooltip": "Always encode the parameters in the request",
|
||||||
|
"enter_otp": "Enter Agent's code",
|
||||||
"expand_navigation": "Expand navigation",
|
"expand_navigation": "Expand navigation",
|
||||||
"experiments": "Experiments",
|
"experiments": "Experiments",
|
||||||
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",
|
"experiments_notice": "This is a collection of experiments we're working on that might turn out to be useful, fun, both, or neither. They're not final and may not be stable, so if something overly weird happens, don't panic. Just turn the dang thing off. Jokes aside, ",
|
||||||
|
|
@ -819,6 +905,8 @@
|
||||||
"general_description": " General settings used in the application",
|
"general_description": " General settings used in the application",
|
||||||
"interceptor": "Interceptor",
|
"interceptor": "Interceptor",
|
||||||
"interceptor_description": "Middleware between application and APIs.",
|
"interceptor_description": "Middleware between application and APIs.",
|
||||||
|
"kernel_interceptor": "Interceptor",
|
||||||
|
"kernel_interceptor_description": "Middleware between application and APIs.",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"light_mode": "Light",
|
"light_mode": "Light",
|
||||||
"official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.",
|
"official_proxy_hosting": "Official Proxy is hosted by Hoppscotch.",
|
||||||
|
|
@ -858,7 +946,33 @@
|
||||||
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
|
"use_experimental_url_bar": "Use experimental URL bar with environment highlighting",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"verified_email": "Verified email",
|
"verified_email": "Verified email",
|
||||||
"verify_email": "Verify email"
|
"verify_email": "Verify email",
|
||||||
|
"validate_certificates": "Validate SSL/TLS Certificates",
|
||||||
|
"verify_host": "Verify Host",
|
||||||
|
"verify_peer": "Verify Peer",
|
||||||
|
"client_certificates": "Client Certificates",
|
||||||
|
"certificate_settings": "Certificate Settings",
|
||||||
|
"certificate": "Certificate",
|
||||||
|
"key": "Private Key",
|
||||||
|
"pfx_or_p12": "PFX/PKCS#12",
|
||||||
|
"password": "Password",
|
||||||
|
"select_file": "Select File",
|
||||||
|
"domain": "Domain",
|
||||||
|
"add_certificate": "Add Certificate",
|
||||||
|
"add_cert_file": "Add Certificate File",
|
||||||
|
"add_key_file": "Add Key File",
|
||||||
|
"add_pfx_file": "Add PFX File",
|
||||||
|
"global_defaults": "Global Defaults",
|
||||||
|
"add_domain_override": "Add Domain Override",
|
||||||
|
"domain_override": "Domain Override",
|
||||||
|
"manage_domains_overrides": "Manage Domains Overrides",
|
||||||
|
"add_domain": "Add Domain",
|
||||||
|
"remove_domain": "Remove Domain",
|
||||||
|
"ca_certificate": "CA Certificate",
|
||||||
|
"ca_certificates": "CA Certificates",
|
||||||
|
"ca_certificates_support": "Hoppscotch supports .crt, .cer or .pem files containing one or more certificates.",
|
||||||
|
"proxy_capabilities": "Hoppscotch Agent and Desktop App supports HTTP/HTTPS/SOCKS proxies with NTLM and Basic Auth support.",
|
||||||
|
"proxy_auth": "You can also include username and password in the URL."
|
||||||
},
|
},
|
||||||
"shared_requests": {
|
"shared_requests": {
|
||||||
"button": "Button",
|
"button": "Button",
|
||||||
|
|
@ -967,7 +1081,8 @@
|
||||||
"code": "Show code",
|
"code": "Show code",
|
||||||
"collection": "Expand Collection Panel",
|
"collection": "Expand Collection Panel",
|
||||||
"more": "Show more",
|
"more": "Show more",
|
||||||
"sidebar": "Expand sidebar"
|
"sidebar": "Expand sidebar",
|
||||||
|
"password": "Show Password"
|
||||||
},
|
},
|
||||||
"socketio": {
|
"socketio": {
|
||||||
"communication": "Communication",
|
"communication": "Communication",
|
||||||
|
|
|
||||||
|
|
@ -34,16 +34,19 @@
|
||||||
"@codemirror/search": "6.5.6",
|
"@codemirror/search": "6.5.6",
|
||||||
"@codemirror/state": "6.4.1",
|
"@codemirror/state": "6.4.1",
|
||||||
"@codemirror/view": "6.25.1",
|
"@codemirror/view": "6.25.1",
|
||||||
|
"@hoppscotch/plugin-appload": "github:CuriousCorrelation/tauri-plugin-appload",
|
||||||
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
|
"@hoppscotch/codemirror-lang-graphql": "workspace:^",
|
||||||
"@hoppscotch/data": "workspace:^",
|
"@hoppscotch/data": "workspace:^",
|
||||||
"@hoppscotch/httpsnippet": "3.0.7",
|
"@hoppscotch/httpsnippet": "3.0.7",
|
||||||
"@hoppscotch/js-sandbox": "workspace:^",
|
"@hoppscotch/js-sandbox": "workspace:^",
|
||||||
|
"@hoppscotch/kernel": "workspace:^",
|
||||||
"@hoppscotch/ui": "0.2.2",
|
"@hoppscotch/ui": "0.2.2",
|
||||||
"@hoppscotch/vue-toasted": "0.1.0",
|
"@hoppscotch/vue-toasted": "0.1.0",
|
||||||
"@lezer/highlight": "1.2.0",
|
"@lezer/highlight": "1.2.0",
|
||||||
"@noble/curves": "1.6.0",
|
"@noble/curves": "1.6.0",
|
||||||
"@scure/base": "1.1.9",
|
"@scure/base": "1.1.9",
|
||||||
"@shopify/lang-jsonc": "1.0.0",
|
"@shopify/lang-jsonc": "1.0.0",
|
||||||
|
"@tauri-apps/plugin-store": "2.2.0",
|
||||||
"@types/markdown-it": "14.1.2",
|
"@types/markdown-it": "14.1.2",
|
||||||
"@unhead/vue": "1.11.10",
|
"@unhead/vue": "1.11.10",
|
||||||
"@urql/core": "5.0.6",
|
"@urql/core": "5.0.6",
|
||||||
|
|
@ -90,6 +93,7 @@
|
||||||
"splitpanes": "3.1.5",
|
"splitpanes": "3.1.5",
|
||||||
"stream-browserify": "3.0.0",
|
"stream-browserify": "3.0.0",
|
||||||
"subscriptions-transport-ws": "0.11.0",
|
"subscriptions-transport-ws": "0.11.0",
|
||||||
|
"superjson": "2.2.2",
|
||||||
"tern": "0.24.3",
|
"tern": "0.24.3",
|
||||||
"timers": "0.1.1",
|
"timers": "0.1.1",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ declare module 'vue' {
|
||||||
AppHeader: typeof import('./components/app/Header.vue')['default']
|
AppHeader: typeof import('./components/app/Header.vue')['default']
|
||||||
AppInspection: typeof import('./components/app/Inspection.vue')['default']
|
AppInspection: typeof import('./components/app/Inspection.vue')['default']
|
||||||
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
AppInterceptor: typeof import('./components/app/Interceptor.vue')['default']
|
||||||
|
AppKernelInterceptor: typeof import('./components/app/KernelInterceptor.vue')['default']
|
||||||
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
AppLogo: typeof import('./components/app/Logo.vue')['default']
|
||||||
AppMarkdown: typeof import('./components/app/Markdown.vue')['default']
|
AppMarkdown: typeof import('./components/app/Markdown.vue')['default']
|
||||||
AppOptions: typeof import('./components/app/Options.vue')['default']
|
AppOptions: typeof import('./components/app/Options.vue')['default']
|
||||||
|
|
@ -230,6 +231,7 @@ declare module 'vue' {
|
||||||
ImportExportImportExportStepsImportSummary: typeof import('./components/importExport/ImportExportSteps/ImportSummary.vue')['default']
|
ImportExportImportExportStepsImportSummary: typeof import('./components/importExport/ImportExportSteps/ImportSummary.vue')['default']
|
||||||
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
|
ImportExportImportExportStepsMyCollectionImport: typeof import('./components/importExport/ImportExportSteps/MyCollectionImport.vue')['default']
|
||||||
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
|
ImportExportImportExportStepsUrlImport: typeof import('./components/importExport/ImportExportSteps/UrlImport.vue')['default']
|
||||||
|
InstanceSwitcher: typeof import('./components/instance/Switcher.vue')['default']
|
||||||
InterceptorsAgentModalNativeCACertificates: typeof import('./components/interceptors/agent/ModalNativeCACertificates.vue')['default']
|
InterceptorsAgentModalNativeCACertificates: typeof import('./components/interceptors/agent/ModalNativeCACertificates.vue')['default']
|
||||||
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
|
InterceptorsAgentModalNativeClientCertificates: typeof import('./components/interceptors/agent/ModalNativeClientCertificates.vue')['default']
|
||||||
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
|
InterceptorsAgentModalNativeClientCertsAdd: typeof import('./components/interceptors/agent/ModalNativeClientCertsAdd.vue')['default']
|
||||||
|
|
@ -255,7 +257,10 @@ declare module 'vue' {
|
||||||
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
|
RealtimeLogEntry: typeof import('./components/realtime/LogEntry.vue')['default']
|
||||||
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
|
RealtimeSubscription: typeof import('./components/realtime/Subscription.vue')['default']
|
||||||
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
|
SettingsAgent: typeof import('./components/settings/Agent.vue')['default']
|
||||||
|
SettingsAgentSubtitle: typeof import('./components/settings/AgentSubtitle.vue')['default']
|
||||||
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
|
SettingsExtension: typeof import('./components/settings/Extension.vue')['default']
|
||||||
|
SettingsExtensionSubtitle: typeof import('./components/settings/ExtensionSubtitle.vue')['default']
|
||||||
|
SettingsNative: typeof import('./components/settings/Native.vue')['default']
|
||||||
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
|
SettingsProxy: typeof import('./components/settings/Proxy.vue')['default']
|
||||||
Share: typeof import('./components/share/index.vue')['default']
|
Share: typeof import('./components/share/index.vue')['default']
|
||||||
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
|
ShareCreateModal: typeof import('./components/share/CreateModal.vue')['default']
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
|
<AppShortcuts :show="showShortcuts" @close="showShortcuts = false" />
|
||||||
<AppShare :show="showShare" @hide-modal="showShare = false" />
|
<AppShare :show="showShare" @hide-modal="showShare = false" />
|
||||||
<FirebaseLogin v-if="showLogin" @hide-modal="showLogin = false" />
|
<FirebaseLogin v-if="showLogin" @hide-modal="showLogin = false" />
|
||||||
|
<InstanceSwitcher
|
||||||
|
v-if="showInstanceSwitcher"
|
||||||
|
@hide-modal="showInstanceSwitcher = false"
|
||||||
|
/>
|
||||||
<HttpResponseInterface
|
<HttpResponseInterface
|
||||||
v-if="isDrawerOpen"
|
v-if="isDrawerOpen"
|
||||||
:show="isDrawerOpen"
|
:show="isDrawerOpen"
|
||||||
|
|
@ -16,6 +20,7 @@ import { defineActionHandler } from "~/helpers/actions"
|
||||||
const showShortcuts = ref(false)
|
const showShortcuts = ref(false)
|
||||||
const showShare = ref(false)
|
const showShare = ref(false)
|
||||||
const showLogin = ref(false)
|
const showLogin = ref(false)
|
||||||
|
const showInstanceSwitcher = ref(false)
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
|
|
||||||
defineActionHandler("flyouts.keybinds.toggle", () => {
|
defineActionHandler("flyouts.keybinds.toggle", () => {
|
||||||
|
|
@ -30,6 +35,10 @@ defineActionHandler("modals.login.toggle", () => {
|
||||||
showLogin.value = !showLogin.value
|
showLogin.value = !showLogin.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineActionHandler("modals.instance-switcher.toggle", () => {
|
||||||
|
showInstanceSwitcher.value = !showInstanceSwitcher.value
|
||||||
|
})
|
||||||
|
|
||||||
defineActionHandler("response.schema.toggle", () => {
|
defineActionHandler("response.schema.toggle", () => {
|
||||||
isDrawerOpen.value = !isDrawerOpen.value
|
isDrawerOpen.value = !isDrawerOpen.value
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
:icon="IconShieldCheck"
|
:icon="IconShieldCheck"
|
||||||
/>
|
/>
|
||||||
<template #content>
|
<template #content>
|
||||||
<AppInterceptor />
|
<AppKernelInterceptor />
|
||||||
</template>
|
</template>
|
||||||
</tippy>
|
</tippy>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@
|
||||||
<div>
|
<div>
|
||||||
<header
|
<header
|
||||||
ref="headerRef"
|
ref="headerRef"
|
||||||
|
data-tauri-drag-region
|
||||||
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
|
class="grid grid-cols-5 grid-rows-1 gap-2 overflow-x-auto overflow-y-hidden p-2"
|
||||||
@mousedown.prevent="platform.ui?.appHeader?.onHeaderAreaClick?.()"
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
class="col-span-2 flex items-center justify-between space-x-2"
|
class="col-span-2 flex items-center justify-between space-x-2"
|
||||||
:style="{
|
:style="{
|
||||||
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
|
paddingTop: platform.ui?.appHeader?.paddingTop?.value,
|
||||||
|
|
@ -13,17 +14,50 @@
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
|
<tippy
|
||||||
|
v-if="kernelMode === 'desktop'"
|
||||||
|
interactive
|
||||||
|
trigger="click"
|
||||||
|
theme="popover"
|
||||||
|
:on-shown="() => instanceSwitcherRef.focus()"
|
||||||
|
>
|
||||||
|
<div class="flex items-center cursor-pointer">
|
||||||
|
<span
|
||||||
|
class="!font-bold uppercase tracking-wide !text-secondaryDark pr-1"
|
||||||
|
>
|
||||||
|
{{ instanceDisplayName }}
|
||||||
|
</span>
|
||||||
|
<IconChevronDown class="h-4 w-4 text-secondaryDark" />
|
||||||
|
</div>
|
||||||
|
<template #content="{ hide }">
|
||||||
|
<div
|
||||||
|
ref="instanceSwitcherRef"
|
||||||
|
class="flex flex-col focus:outline-none min-w-64"
|
||||||
|
tabindex="0"
|
||||||
|
@keyup.escape="hide()"
|
||||||
|
>
|
||||||
|
<InstanceSwitcher @close-dropdown="hide()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</tippy>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
|
v-else
|
||||||
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
|
class="!font-bold uppercase tracking-wide !text-secondaryDark hover:bg-primaryDark focus-visible:bg-primaryDark"
|
||||||
:label="t('app.name')"
|
:label="t('app.name')"
|
||||||
to="/"
|
to="/"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-1 flex items-center justify-between space-x-2">
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
class="col-span-1 flex items-center justify-between space-x-2"
|
||||||
|
>
|
||||||
<AppSpotlightSearch />
|
<AppSpotlightSearch />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2 flex items-center justify-between space-x-2">
|
<div
|
||||||
|
data-tauri-drag-region
|
||||||
|
class="col-span-2 flex items-center justify-between space-x-2"
|
||||||
|
>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
v-if="showInstallButton"
|
v-if="showInstallButton"
|
||||||
|
|
@ -177,9 +211,8 @@
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
class="inline-flex truncate text-secondaryLight text-tiny"
|
class="inline-flex truncate text-secondaryLight text-tiny"
|
||||||
|
>{{ currentUser.email }}</span
|
||||||
>
|
>
|
||||||
{{ currentUser.email }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<HoppSmartItem
|
<HoppSmartItem
|
||||||
|
|
@ -241,6 +274,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { getKernelMode } from "@hoppscotch/kernel"
|
||||||
|
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useReadonlyStream } from "@composables/stream"
|
import { useReadonlyStream } from "@composables/stream"
|
||||||
import { defineActionHandler, invokeAction } from "@helpers/actions"
|
import { defineActionHandler, invokeAction } from "@helpers/actions"
|
||||||
|
|
@ -260,6 +295,7 @@ import {
|
||||||
BannerService,
|
BannerService,
|
||||||
} from "~/services/banner.service"
|
} from "~/services/banner.service"
|
||||||
import { WorkspaceService } from "~/services/workspace.service"
|
import { WorkspaceService } from "~/services/workspace.service"
|
||||||
|
import { InstanceSwitcherService } from "~/services/instance-switcher.service"
|
||||||
import IconDownload from "~icons/lucide/download"
|
import IconDownload from "~icons/lucide/download"
|
||||||
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
import IconLifeBuoy from "~icons/lucide/life-buoy"
|
||||||
import IconSettings from "~icons/lucide/settings"
|
import IconSettings from "~icons/lucide/settings"
|
||||||
|
|
@ -267,9 +303,33 @@ import IconUploadCloud from "~icons/lucide/upload-cloud"
|
||||||
import IconUser from "~icons/lucide/user"
|
import IconUser from "~icons/lucide/user"
|
||||||
import IconUserPlus from "~icons/lucide/user-plus"
|
import IconUserPlus from "~icons/lucide/user-plus"
|
||||||
import IconUsers from "~icons/lucide/users"
|
import IconUsers from "~icons/lucide/users"
|
||||||
|
import IconChevronDown from "~icons/lucide/chevron-down"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const kernelMode = getKernelMode()
|
||||||
|
const instanceSwitcherService =
|
||||||
|
kernelMode === "desktop" ? useService(InstanceSwitcherService) : null
|
||||||
|
const instanceSwitcherRef =
|
||||||
|
kernelMode === "desktop" ? ref<any | null>(null) : ref(null)
|
||||||
|
|
||||||
|
const currentState =
|
||||||
|
kernelMode === "desktop" && instanceSwitcherService
|
||||||
|
? useReadonlyStream(
|
||||||
|
instanceSwitcherService.getStateStream(),
|
||||||
|
instanceSwitcherService.getCurrentState().value
|
||||||
|
)
|
||||||
|
: ref({
|
||||||
|
status: "disconnected",
|
||||||
|
instance: { displayName: "Hoppscotch" },
|
||||||
|
})
|
||||||
|
|
||||||
|
const instanceDisplayName = computed(() => {
|
||||||
|
if (currentState.value.status !== "connected") {
|
||||||
|
return "Hoppscotch"
|
||||||
|
}
|
||||||
|
return currentState.value.instance.displayName
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Feature flag to enable the workspace selector login conversion
|
* Feature flag to enable the workspace selector login conversion
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -54,7 +54,7 @@ import "splitpanes/dist/splitpanes.css"
|
||||||
import { useSetting } from "@composables/settings"
|
import { useSetting } from "@composables/settings"
|
||||||
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
|
import { breakpointsTailwind, useBreakpoints } from "@vueuse/core"
|
||||||
import { useService } from "dioc/vue"
|
import { useService } from "dioc/vue"
|
||||||
import { computed, ref, useSlots } from "vue"
|
import { computed, onMounted, ref, useSlots } from "vue"
|
||||||
import { PersistenceService } from "~/services/persistence"
|
import { PersistenceService } from "~/services/persistence"
|
||||||
|
|
||||||
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
const SIDEBAR_ON_LEFT = useSetting("SIDEBAR_ON_LEFT")
|
||||||
|
|
@ -104,23 +104,26 @@ if (!COLUMN_LAYOUT.value) {
|
||||||
PANE_MAIN_BOTTOM_SIZE.value = 50
|
PANE_MAIN_BOTTOM_SIZE.value = 50
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPaneEvent(event: PaneEvent[], type: "vertical" | "horizontal") {
|
async function setPaneEvent(
|
||||||
|
event: PaneEvent[],
|
||||||
|
type: "vertical" | "horizontal"
|
||||||
|
) {
|
||||||
if (!props.layoutId) return
|
if (!props.layoutId) return
|
||||||
const storageKey = `${props.layoutId}-pane-config-${type}`
|
const storageKey = `${props.layoutId}-pane-config-${type}`
|
||||||
persistenceService.setLocalConfig(storageKey, JSON.stringify(event))
|
await persistenceService.setLocalConfig(storageKey, JSON.stringify(event))
|
||||||
}
|
}
|
||||||
|
|
||||||
function populatePaneEvent() {
|
async function populatePaneEvent() {
|
||||||
if (!props.layoutId) return
|
if (!props.layoutId) return
|
||||||
|
|
||||||
const verticalPaneData = getPaneData("vertical")
|
const verticalPaneData = await getPaneData("vertical")
|
||||||
if (verticalPaneData) {
|
if (verticalPaneData) {
|
||||||
const [mainPane, sidebarPane] = verticalPaneData
|
const [mainPane, sidebarPane] = verticalPaneData
|
||||||
PANE_MAIN_SIZE.value = mainPane?.size
|
PANE_MAIN_SIZE.value = mainPane?.size
|
||||||
PANE_SIDEBAR_SIZE.value = sidebarPane?.size
|
PANE_SIDEBAR_SIZE.value = sidebarPane?.size
|
||||||
}
|
}
|
||||||
|
|
||||||
const horizontalPaneData = getPaneData("horizontal")
|
const horizontalPaneData = await getPaneData("horizontal")
|
||||||
if (horizontalPaneData) {
|
if (horizontalPaneData) {
|
||||||
const [mainTopPane, mainBottomPane] = horizontalPaneData
|
const [mainTopPane, mainBottomPane] = horizontalPaneData
|
||||||
PANE_MAIN_TOP_SIZE.value = mainTopPane?.size
|
PANE_MAIN_TOP_SIZE.value = mainTopPane?.size
|
||||||
|
|
@ -128,12 +131,16 @@ function populatePaneEvent() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPaneData(type: "vertical" | "horizontal"): PaneEvent[] | null {
|
async function getPaneData(
|
||||||
|
type: "vertical" | "horizontal"
|
||||||
|
): Promise<PaneEvent[] | null> {
|
||||||
const storageKey = `${props.layoutId}-pane-config-${type}`
|
const storageKey = `${props.layoutId}-pane-config-${type}`
|
||||||
const paneEvent = persistenceService.getLocalConfig(storageKey)
|
const paneEvent = await persistenceService.getLocalConfig(storageKey)
|
||||||
if (!paneEvent) return null
|
if (!paneEvent) return null
|
||||||
return JSON.parse(paneEvent)
|
return JSON.parse(paneEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
populatePaneEvent()
|
onMounted(async () => {
|
||||||
|
await populatePaneEvent()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const openWhatsNew = () => {
|
const openWhatsNew = () => {
|
||||||
if (props.notesUrl) platform.io.openExternalLink(props.notesUrl)
|
if (props.notesUrl) platform.kernelIO.openExternalLink(props.notesUrl)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,9 @@ import {
|
||||||
} from "~/services/spotlight/searchers/environment.searcher"
|
} from "~/services/spotlight/searchers/environment.searcher"
|
||||||
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
|
import { GeneralSpotlightSearcherService } from "~/services/spotlight/searchers/general.searcher"
|
||||||
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
import { HistorySpotlightSearcherService } from "~/services/spotlight/searchers/history.searcher"
|
||||||
import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
|
// NOTE: Old interceptors
|
||||||
|
// import { InterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/interceptor.searcher"
|
||||||
|
import { KernelInterceptorSpotlightSearcherService } from "~/services/spotlight/searchers/kernel-interceptor.searcher"
|
||||||
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
|
import { MiscellaneousSpotlightSearcherService } from "~/services/spotlight/searchers/miscellaneous.searcher"
|
||||||
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
|
import { NavigationSpotlightSearcherService } from "~/services/spotlight/searchers/navigation.searcher"
|
||||||
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
|
import { RequestSpotlightSearcherService } from "~/services/spotlight/searchers/request.searcher"
|
||||||
|
|
@ -144,7 +146,9 @@ useService(EnvironmentsSpotlightSearcherService)
|
||||||
useService(SwitchEnvSpotlightSearcherService)
|
useService(SwitchEnvSpotlightSearcherService)
|
||||||
useService(WorkspaceSpotlightSearcherService)
|
useService(WorkspaceSpotlightSearcherService)
|
||||||
useService(SwitchWorkspaceSpotlightSearcherService)
|
useService(SwitchWorkspaceSpotlightSearcherService)
|
||||||
useService(InterceptorSpotlightSearcherService)
|
// NOTE: Old interceptors
|
||||||
|
// useService(InterceptorSpotlightSearcherService)
|
||||||
|
useService(KernelInterceptorSpotlightSearcherService)
|
||||||
useService(TeamsSpotlightSearcherService)
|
useService(TeamsSpotlightSearcherService)
|
||||||
|
|
||||||
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
|
platform.spotlight?.additionalSearchers?.forEach((searcher) =>
|
||||||
|
|
|
||||||
|
|
@ -625,7 +625,7 @@ const HoppGistCollectionsExporter: ImporterOrExporter = {
|
||||||
platform: "rest",
|
platform: "rest",
|
||||||
})
|
})
|
||||||
|
|
||||||
platform.io.openExternalLink(res.right)
|
platform.kernelIO.openExternalLink(res.right)
|
||||||
} else {
|
} else {
|
||||||
toast.error(collectionJSON.left)
|
toast.error(collectionJSON.left)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ const activeTabIsDetails = computed(() => activeTab.value === "details")
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
editableCollection,
|
editableCollection,
|
||||||
(updatedEditableCollection) => {
|
async (updatedEditableCollection) => {
|
||||||
if (props.show && props.editingProperties) {
|
if (props.show && props.editingProperties) {
|
||||||
const unsavedCollectionProperties: EditingProperties = {
|
const unsavedCollectionProperties: EditingProperties = {
|
||||||
collection: updatedEditableCollection,
|
collection: updatedEditableCollection,
|
||||||
|
|
@ -207,7 +207,7 @@ watch(
|
||||||
path: props.editingProperties.path,
|
path: props.editingProperties.path,
|
||||||
inheritedProperties: props.editingProperties.inheritedProperties,
|
inheritedProperties: props.editingProperties.inheritedProperties,
|
||||||
}
|
}
|
||||||
persistenceService.setLocalConfig(
|
await persistenceService.setLocalConfig(
|
||||||
"unsaved_collection_properties",
|
"unsaved_collection_properties",
|
||||||
JSON.stringify(unsavedCollectionProperties)
|
JSON.stringify(unsavedCollectionProperties)
|
||||||
)
|
)
|
||||||
|
|
@ -222,7 +222,7 @@ const activeTab = useVModel(props, "modelValue", emit)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.show,
|
() => props.show,
|
||||||
(show) => {
|
async (show) => {
|
||||||
// `Details` tab doesn't exist for personal workspace, hence switching to the `Headers` tab
|
// `Details` tab doesn't exist for personal workspace, hence switching to the `Headers` tab
|
||||||
// The modal can appear empty while switching from a team workspace with `Details` as the active tab
|
// The modal can appear empty while switching from a team workspace with `Details` as the active tab
|
||||||
if (activeTab.value === "details" && !props.showDetails) {
|
if (activeTab.value === "details" && !props.showDetails) {
|
||||||
|
|
@ -245,12 +245,14 @@ watch(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
persistenceService.removeLocalConfig("unsaved_collection_properties")
|
await persistenceService.removeLocalConfig(
|
||||||
|
"unsaved_collection_properties"
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const saveEditedCollection = () => {
|
const saveEditedCollection = async () => {
|
||||||
if (!props.editingProperties) return
|
if (!props.editingProperties) return
|
||||||
const finalCollection = clone(editableCollection.value)
|
const finalCollection = clone(editableCollection.value)
|
||||||
const collection = {
|
const collection = {
|
||||||
|
|
@ -262,11 +264,11 @@ const saveEditedCollection = () => {
|
||||||
isRootCollection: props.editingProperties.isRootCollection,
|
isRootCollection: props.editingProperties.isRootCollection,
|
||||||
}
|
}
|
||||||
emit("set-collection-properties", collection as EditingProperties)
|
emit("set-collection-properties", collection as EditingProperties)
|
||||||
persistenceService.removeLocalConfig("unsaved_collection_properties")
|
await persistenceService.removeLocalConfig("unsaved_collection_properties")
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideModal = () => {
|
const hideModal = async () => {
|
||||||
persistenceService.removeLocalConfig("unsaved_collection_properties")
|
await persistenceService.removeLocalConfig("unsaved_collection_properties")
|
||||||
emit("hide-modal")
|
emit("hide-modal")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -208,7 +208,7 @@ const GqlCollectionsGistExporter: ImporterOrExporter = {
|
||||||
exporter: "gist",
|
exporter: "gist",
|
||||||
})
|
})
|
||||||
|
|
||||||
platform.io.openExternalLink(res.right)
|
platform.kernelIO.openExternalLink(res.right)
|
||||||
}
|
}
|
||||||
|
|
||||||
isGqlCollectionGistExportInProgress.value = false
|
isGqlCollectionGistExportInProgress.value = false
|
||||||
|
|
|
||||||
|
|
@ -243,9 +243,9 @@ const persistenceService = useService(PersistenceService)
|
||||||
|
|
||||||
const collectionPropertiesModalActiveTab = ref<GQLOptionTabs>("headers")
|
const collectionPropertiesModalActiveTab = ref<GQLOptionTabs>("headers")
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const localOAuthTempConfig =
|
const localOAuthTempConfig =
|
||||||
persistenceService.getLocalConfig("oauth_temp_config")
|
await persistenceService.getLocalConfig("oauth_temp_config")
|
||||||
|
|
||||||
if (!localOAuthTempConfig) {
|
if (!localOAuthTempConfig) {
|
||||||
return
|
return
|
||||||
|
|
@ -260,9 +260,8 @@ onMounted(() => {
|
||||||
|
|
||||||
if (context?.type === "collection-properties") {
|
if (context?.type === "collection-properties") {
|
||||||
// load the unsaved editing properties
|
// load the unsaved editing properties
|
||||||
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig(
|
const unsavedCollectionPropertiesString =
|
||||||
"unsaved_collection_properties"
|
await persistenceService.getLocalConfig("unsaved_collection_properties")
|
||||||
)
|
|
||||||
|
|
||||||
if (unsavedCollectionPropertiesString) {
|
if (unsavedCollectionPropertiesString) {
|
||||||
const unsavedCollectionProperties: EditingProperties = JSON.parse(
|
const unsavedCollectionProperties: EditingProperties = JSON.parse(
|
||||||
|
|
@ -284,7 +283,7 @@ onMounted(() => {
|
||||||
editingProperties.value = unsavedCollectionProperties
|
editingProperties.value = unsavedCollectionProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
persistenceService.removeLocalConfig("oauth_temp_config")
|
await persistenceService.removeLocalConfig("oauth_temp_config")
|
||||||
collectionPropertiesModalActiveTab.value = "authorization"
|
collectionPropertiesModalActiveTab.value = "authorization"
|
||||||
showModalEditProperties.value = true
|
showModalEditProperties.value = true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -440,9 +440,9 @@ const persistenceService = useService(PersistenceService)
|
||||||
|
|
||||||
const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers")
|
const collectionPropertiesModalActiveTab = ref<RESTOptionTabs>("headers")
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const localOAuthTempConfig =
|
const localOAuthTempConfig =
|
||||||
persistenceService.getLocalConfig("oauth_temp_config")
|
await persistenceService.getLocalConfig("oauth_temp_config")
|
||||||
|
|
||||||
if (!localOAuthTempConfig) {
|
if (!localOAuthTempConfig) {
|
||||||
return
|
return
|
||||||
|
|
@ -457,9 +457,8 @@ onMounted(() => {
|
||||||
|
|
||||||
if (context?.type === "collection-properties") {
|
if (context?.type === "collection-properties") {
|
||||||
// load the unsaved editing properties
|
// load the unsaved editing properties
|
||||||
const unsavedCollectionPropertiesString = persistenceService.getLocalConfig(
|
const unsavedCollectionPropertiesString =
|
||||||
"unsaved_collection_properties"
|
await persistenceService.getLocalConfig("unsaved_collection_properties")
|
||||||
)
|
|
||||||
|
|
||||||
if (unsavedCollectionPropertiesString) {
|
if (unsavedCollectionPropertiesString) {
|
||||||
const unsavedCollectionProperties: EditingProperties = JSON.parse(
|
const unsavedCollectionProperties: EditingProperties = JSON.parse(
|
||||||
|
|
@ -481,7 +480,7 @@ onMounted(() => {
|
||||||
editingProperties.value = unsavedCollectionProperties
|
editingProperties.value = unsavedCollectionProperties
|
||||||
}
|
}
|
||||||
|
|
||||||
persistenceService.removeLocalConfig("oauth_temp_config")
|
await persistenceService.removeLocalConfig("oauth_temp_config")
|
||||||
collectionPropertiesModalActiveTab.value = "authorization"
|
collectionPropertiesModalActiveTab.value = "authorization"
|
||||||
showModalEditProperties.value = true
|
showModalEditProperties.value = true
|
||||||
}
|
}
|
||||||
|
|
@ -2642,7 +2641,7 @@ const initializeDownloadCollection = async (
|
||||||
collectionJSON: string,
|
collectionJSON: string,
|
||||||
name: string | null
|
name: string | null
|
||||||
) => {
|
) => {
|
||||||
const result = await platform.io.saveFileWithDialog({
|
const result = await platform.kernelIO.saveFileWithDialog({
|
||||||
data: collectionJSON,
|
data: collectionJSON,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
suggestedFilename: `${name ?? "collection"}.json`,
|
suggestedFilename: `${name ?? "collection"}.json`,
|
||||||
|
|
|
||||||
|
|
@ -149,10 +149,10 @@ import IconTrash2 from "~icons/lucide/trash-2"
|
||||||
import IconPlus from "~icons/lucide/plus"
|
import IconPlus from "~icons/lucide/plus"
|
||||||
import { cloneDeep } from "lodash-es"
|
import { cloneDeep } from "lodash-es"
|
||||||
import { ref, watch, computed } from "vue"
|
import { ref, watch, computed } from "vue"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
|
||||||
import { EditCookieConfig } from "./EditCookie.vue"
|
import { EditCookieConfig } from "./EditCookie.vue"
|
||||||
import { useColorMode } from "@composables/theming"
|
import { useColorMode } from "@composables/theming"
|
||||||
import { useToast } from "@composables/toast"
|
import { useToast } from "@composables/toast"
|
||||||
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
show: boolean
|
show: boolean
|
||||||
|
|
@ -168,17 +168,16 @@ const toast = useToast()
|
||||||
|
|
||||||
const newDomainText = ref("")
|
const newDomainText = ref("")
|
||||||
|
|
||||||
const interceptorService = useService(InterceptorService)
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
const cookieJarService = useService(CookieJarService)
|
const cookieJarService = useService(CookieJarService)
|
||||||
|
|
||||||
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
|
const workingCookieJar = ref(cloneDeep(cookieJarService.cookieJar.value))
|
||||||
|
|
||||||
const currentInterceptorSupportsCookies = computed(() => {
|
const currentInterceptorSupportsCookies = computed(() => {
|
||||||
const currentInterceptor = interceptorService.currentInterceptor.value
|
const capabilities = interceptorService.current.value?.capabilities
|
||||||
|
const supportsCookies = capabilities["advanced"].has("cookies")
|
||||||
|
|
||||||
if (!currentInterceptor) return true
|
return supportsCookies ?? false
|
||||||
|
|
||||||
return currentInterceptor.supportsCookies ?? false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function addNewDomain() {
|
function addNewDomain() {
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ const HoppEnvironmentsGistExporter: ImporterOrExporter = {
|
||||||
platform: "rest",
|
platform: "rest",
|
||||||
})
|
})
|
||||||
|
|
||||||
platform.io.openExternalLink(res.right)
|
platform.kernelIO.openExternalLink(res.right)
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnvironmentGistExportInProgress.value = false
|
isEnvironmentGistExportInProgress.value = false
|
||||||
|
|
|
||||||
|
|
@ -316,9 +316,9 @@ const signInWithEmail = async () => {
|
||||||
|
|
||||||
await platform.auth
|
await platform.auth
|
||||||
.signInWithEmail(form.email)
|
.signInWithEmail(form.email)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
mode.value = "email-sent"
|
mode.value = "email-sent"
|
||||||
persistenceService.setLocalConfig("emailForSignIn", form.email)
|
await persistenceService.setLocalConfig("emailForSignIn", form.email)
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ import { computed, ref, watch } from "vue"
|
||||||
import { connection } from "~/helpers/graphql/connection"
|
import { connection } from "~/helpers/graphql/connection"
|
||||||
import { connect } from "~/helpers/graphql/connection"
|
import { connect } from "~/helpers/graphql/connection"
|
||||||
import { disconnect } from "~/helpers/graphql/connection"
|
import { disconnect } from "~/helpers/graphql/connection"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
import { useService } from "dioc/vue"
|
import { useService } from "dioc/vue"
|
||||||
import { defineActionHandler } from "~/helpers/actions"
|
import { defineActionHandler } from "~/helpers/actions"
|
||||||
import { GQLTabService } from "~/services/tab/graphql"
|
import { GQLTabService } from "~/services/tab/graphql"
|
||||||
|
|
@ -77,7 +77,7 @@ import { HoppGQLAuth, HoppGQLRequest } from "@hoppscotch/data"
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const tabs = useService(GQLTabService)
|
const tabs = useService(GQLTabService)
|
||||||
|
|
||||||
const interceptorService = useService(InterceptorService)
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
|
|
||||||
const connectionSwitchModal = ref(false)
|
const connectionSwitchModal = ref(false)
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@ const gqlConnect = () => {
|
||||||
platform.analytics?.logEvent({
|
platform.analytics?.logEvent({
|
||||||
type: "HOPP_REQUEST_RUN",
|
type: "HOPP_REQUEST_RUN",
|
||||||
platform: "graphql-schema",
|
platform: "graphql-schema",
|
||||||
strategy: interceptorService.currentInterceptorID.value!,
|
strategy: interceptorService.current.value!.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ import { HoppInheritedProperty } from "~/helpers/types/HoppInheritedProperties"
|
||||||
import { completePageProgress, startPageProgress } from "~/modules/loadingbar"
|
import { completePageProgress, startPageProgress } from "~/modules/loadingbar"
|
||||||
import { editGraphqlRequest } from "~/newstore/collections"
|
import { editGraphqlRequest } from "~/newstore/collections"
|
||||||
import { platform } from "~/platform"
|
import { platform } from "~/platform"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
import { GQLTabService } from "~/services/tab/graphql"
|
import { GQLTabService } from "~/services/tab/graphql"
|
||||||
|
|
||||||
const VALID_GQL_OPERATIONS = [
|
const VALID_GQL_OPERATIONS = [
|
||||||
|
|
@ -86,7 +86,7 @@ const VALID_GQL_OPERATIONS = [
|
||||||
|
|
||||||
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
|
export type GQLOptionTabs = (typeof VALID_GQL_OPERATIONS)[number]
|
||||||
|
|
||||||
const interceptorService = useService(InterceptorService)
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
@ -174,7 +174,7 @@ const runQuery = async (
|
||||||
platform.analytics?.logEvent({
|
platform.analytics?.logEvent({
|
||||||
type: "HOPP_REQUEST_RUN",
|
type: "HOPP_REQUEST_RUN",
|
||||||
platform: "graphql-query",
|
platform: "graphql-query",
|
||||||
strategy: interceptorService.currentInterceptorID.value!,
|
strategy: interceptorService.current.value!.id,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ const downloadSchema = async () => {
|
||||||
|
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
|
|
||||||
const result = await platform.io.saveFileWithDialog({
|
const result = await platform.kernelIO.saveFileWithDialog({
|
||||||
data: dataToWrite,
|
data: dataToWrite,
|
||||||
contentType: "application/graphql",
|
contentType: "application/graphql",
|
||||||
suggestedFilename: filename,
|
suggestedFilename: filename,
|
||||||
|
|
|
||||||
|
|
@ -261,15 +261,15 @@ import { platform } from "~/platform"
|
||||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||||
import { useService } from "dioc/vue"
|
import { useService } from "dioc/vue"
|
||||||
import { InspectionService } from "~/services/inspection"
|
import { InspectionService } from "~/services/inspection"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
|
||||||
import { HoppTab } from "~/services/tab"
|
import { HoppTab } from "~/services/tab"
|
||||||
import { HoppRequestDocument } from "~/helpers/rest/document"
|
import { HoppRequestDocument } from "~/helpers/rest/document"
|
||||||
import { RESTTabService } from "~/services/tab/rest"
|
import { RESTTabService } from "~/services/tab/rest"
|
||||||
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
import { getMethodLabelColor } from "~/helpers/rest/labelColoring"
|
||||||
import { WorkspaceService } from "~/services/workspace.service"
|
import { WorkspaceService } from "~/services/workspace.service"
|
||||||
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const interceptorService = useService(InterceptorService)
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
|
|
||||||
const methods = [
|
const methods = [
|
||||||
"GET",
|
"GET",
|
||||||
|
|
@ -348,7 +348,7 @@ const newSendRequest = async () => {
|
||||||
platform.analytics?.logEvent({
|
platform.analytics?.logEvent({
|
||||||
type: "HOPP_REQUEST_RUN",
|
type: "HOPP_REQUEST_RUN",
|
||||||
platform: "rest",
|
platform: "rest",
|
||||||
strategy: interceptorService.currentInterceptorID.value!,
|
strategy: interceptorService.current.value!.id,
|
||||||
workspaceType: workspaceService.currentWorkspace.value.type,
|
workspaceType: workspaceService.currentWorkspace.value.type,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,26 @@
|
||||||
v-if="response.type === 'extension_error'"
|
v-if="response.type === 'extension_error'"
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
/>
|
/>
|
||||||
|
<HoppSmartPlaceholder
|
||||||
|
v-if="response.type === 'interceptor_error'"
|
||||||
|
:src="`/images/states/${colorMode.value}/upload_error.svg`"
|
||||||
|
:alt="
|
||||||
|
response.error?.humanMessage?.heading?.(t) || t('error.network_fail')
|
||||||
|
"
|
||||||
|
:heading="
|
||||||
|
response.error?.humanMessage?.heading?.(t) || t('error.network_fail')
|
||||||
|
"
|
||||||
|
:text="
|
||||||
|
response.error?.humanMessage?.description?.(t) ||
|
||||||
|
t('error.network_fail')
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<AppKernelInterceptor
|
||||||
|
class="rounded border border-dividerLight p-2"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</HoppSmartPlaceholder>
|
||||||
<HoppSmartPlaceholder
|
<HoppSmartPlaceholder
|
||||||
v-if="response.type === 'network_fail'"
|
v-if="response.type === 'network_fail'"
|
||||||
:src="`/images/states/${colorMode.value}/upload_error.svg`"
|
:src="`/images/states/${colorMode.value}/upload_error.svg`"
|
||||||
|
|
|
||||||
|
|
@ -1084,7 +1084,7 @@ const generateOAuthToken = async () => {
|
||||||
: { type: "request-tab", metadata: {} },
|
: { type: "request-tab", metadata: {} },
|
||||||
grant_type: auth.value.grantTypeInfo.grantType,
|
grant_type: auth.value.grantTypeInfo.grantType,
|
||||||
}
|
}
|
||||||
persistenceService.setLocalConfig(
|
await persistenceService.setLocalConfig(
|
||||||
"oauth_temp_config",
|
"oauth_temp_config",
|
||||||
JSON.stringify(authConfig)
|
JSON.stringify(authConfig)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,13 @@
|
||||||
import { computed, ref, watch } from "vue"
|
import { computed, ref, watch } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
import { useI18n } from "@composables/i18n"
|
||||||
import { useToast } from "~/composables/toast"
|
import { useToast } from "~/composables/toast"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
import { useService } from "dioc/vue"
|
import { useService } from "dioc/vue"
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
|
import { parseBodyAsJSON } from "~/helpers/functional/json"
|
||||||
|
|
||||||
const interceptorService = useService(InterceptorService)
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
|
||||||
|
|
@ -83,33 +85,26 @@ const disableImportCTA = computed(() => !hasURL.value || props.loading)
|
||||||
const urlFetchLogic =
|
const urlFetchLogic =
|
||||||
props.fetchLogic ??
|
props.fetchLogic ??
|
||||||
async function (url: string) {
|
async function (url: string) {
|
||||||
const res = await interceptorService.runRequest({
|
const { response } = interceptorService.execute({
|
||||||
|
id: Date.now(),
|
||||||
url: url,
|
url: url,
|
||||||
transitional: {
|
method: "GET",
|
||||||
forcedJSONParsing: false,
|
version: "HTTP/1.1",
|
||||||
silentJSONParsing: false,
|
|
||||||
clarifyTimeoutError: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await res.response
|
const res = await response
|
||||||
|
|
||||||
if (E.isLeft(response)) {
|
if (E.isLeft(res)) {
|
||||||
return E.left("REQUEST_FAILED")
|
return E.left("REQUEST_FAILED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert ArrayBuffer to string
|
const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
|
||||||
if (!(response.right.data instanceof ArrayBuffer)) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
if (O.isSome(responsePayload)) {
|
||||||
|
return E.right(responsePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return E.left("REQUEST_FAILED")
|
||||||
return E.right(
|
|
||||||
InterceptorService.convertArrayBufferToString(response.right.data)
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUrlData() {
|
async function fetchUrlData() {
|
||||||
|
|
|
||||||
380
packages/hoppscotch-common/src/components/instance/Switcher.vue
Normal file
380
packages/hoppscotch-common/src/components/instance/Switcher.vue
Normal 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>
|
||||||
|
|
@ -137,15 +137,15 @@ const { downloadIcon, downloadResponse } = useDownloadResponse(
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const defaultPreview =
|
const defaultPreview =
|
||||||
persistenceService.getLocalConfig("lens_html_preview") === "true"
|
(await persistenceService.getLocalConfig("lens_html_preview")) === "true"
|
||||||
|
|
||||||
const { previewFrame, previewEnabled, togglePreview } = usePreview(
|
const { previewFrame, previewEnabled, togglePreview } = usePreview(
|
||||||
defaultPreview,
|
defaultPreview,
|
||||||
responseBodyText
|
responseBodyText
|
||||||
)
|
)
|
||||||
|
|
||||||
const doTogglePreview = () => {
|
const doTogglePreview = async () => {
|
||||||
persistenceService.setLocalConfig(
|
await persistenceService.setLocalConfig(
|
||||||
"lens_html_preview",
|
"lens_html_preview",
|
||||||
previewEnabled.value ? "false" : "true"
|
previewEnabled.value ? "false" : "true"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,106 +1,661 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="py-4 space-y-4">
|
<div class="flex flex-col">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center space-x-2 py-4">
|
||||||
<HoppSmartToggle
|
<h2 class="font-semibold flex-1 truncate">{{ selectedDomainDisplay }}</h2>
|
||||||
:on="allowSSLVerification"
|
|
||||||
@change="allowSSLVerification = !allowSSLVerification"
|
|
||||||
/>
|
|
||||||
{{ t("agent.verify_ssl_certs") }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
:icon="IconLucideFileBadge"
|
v-tippy="{
|
||||||
:label="'CA Certificates'"
|
theme: 'tooltip',
|
||||||
|
content: t('settings.manage_domains_overrides'),
|
||||||
|
}"
|
||||||
|
:icon="IconSettings"
|
||||||
outline
|
outline
|
||||||
@click="showCACertificatesModal = true"
|
class="rounded"
|
||||||
/>
|
@click="showDomainModal = true"
|
||||||
<HoppButtonSecondary
|
|
||||||
:icon="IconLucideFileKey"
|
|
||||||
:label="t('agent.client_certs')"
|
|
||||||
outline
|
|
||||||
@click="showClientCertificatesModal = true"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<InterceptorsAgentModalNativeCACertificates
|
<div class="flex flex-col space-y-4">
|
||||||
:show="showCACertificatesModal"
|
|
||||||
@hide-modal="showCACertificatesModal = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<InterceptorsAgentModalNativeClientCertificates
|
|
||||||
:show="showClientCertificatesModal"
|
|
||||||
@hide-modal="showClientCertificatesModal = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="pt-4 space-y-4">
|
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<HoppSmartToggle :on="allowProxy" @change="allowProxy = !allowProxy" />
|
<HoppSmartToggle
|
||||||
{{ t("agent.use_http_proxy") }}
|
:on="domainSettings[selectedDomain]?.security?.verifyHost"
|
||||||
|
@change="toggleVerifyHost"
|
||||||
|
/>
|
||||||
|
{{ t("settings.verify_host") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HoppSmartInput
|
<div class="flex items-center">
|
||||||
v-if="allowProxy"
|
<HoppSmartToggle
|
||||||
v-model="proxyURL"
|
:on="domainSettings[selectedDomain]?.security?.verifyPeer"
|
||||||
:autofocus="false"
|
@change="toggleVerifyPeer"
|
||||||
styles="flex-1"
|
/>
|
||||||
placeholder=" "
|
{{ t("settings.verify_peer") }}
|
||||||
:label="t('settings.proxy_url')"
|
</div>
|
||||||
input-styles="input floating-input"
|
|
||||||
/>
|
|
||||||
|
|
||||||
|
<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">
|
<p class="my-1 text-secondaryLight">
|
||||||
{{ t("agent.proxy_capabilities") }}
|
{{ t("settings.proxy_capabilities") }}
|
||||||
</p>
|
</p>
|
||||||
|
<div
|
||||||
|
v-if="domainSettings[selectedDomain]?.proxy"
|
||||||
|
class="flex flex-col space-y-2"
|
||||||
|
>
|
||||||
|
<HoppSmartInput
|
||||||
|
:model-value="domainSettings[selectedDomain].proxy.url"
|
||||||
|
:placeholder="' '"
|
||||||
|
:label="t('settings.proxy_url')"
|
||||||
|
input-styles="floating-input !border-0"
|
||||||
|
@update:model-value="updateProxyUrl"
|
||||||
|
/>
|
||||||
|
<p class="my-1 text-secondaryLight">
|
||||||
|
{{ t("settings.proxy_auth") }}
|
||||||
|
</p>
|
||||||
|
<div class="flex">
|
||||||
|
<HoppSmartInput
|
||||||
|
:model-value="domainSettings[selectedDomain].proxy.username"
|
||||||
|
:placeholder="' '"
|
||||||
|
:label="t('authorization.username')"
|
||||||
|
input-styles="floating-input !border-0"
|
||||||
|
@update:model-value="updateProxyUsername"
|
||||||
|
/>
|
||||||
|
<HoppSmartInput
|
||||||
|
:model-value="domainSettings[selectedDomain].proxy.password"
|
||||||
|
:placeholder="' '"
|
||||||
|
:label="t('authorization.password')"
|
||||||
|
input-styles="floating-input !border-0"
|
||||||
|
:type="showProxyPassword ? 'text' : 'password'"
|
||||||
|
@update:model-value="updateProxyPassword"
|
||||||
|
>
|
||||||
|
<template #button>
|
||||||
|
<HoppButtonSecondary
|
||||||
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
:title="
|
||||||
|
showProxyPassword ? t('hide.password') : t('show.password')
|
||||||
|
"
|
||||||
|
:icon="showProxyPassword ? IconEye : IconEyeOff"
|
||||||
|
@click="showProxyPassword = !showProxyPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</HoppSmartInput>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<HoppSmartModal
|
||||||
|
v-if="showDomainModal"
|
||||||
|
:title="t('settings.manage_domains_overrides')"
|
||||||
|
@close="showDomainModal = false"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div class="space-y-4 p-4">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<HoppSmartInput
|
||||||
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue"
|
import { ref, reactive, computed, onMounted } from "vue"
|
||||||
import { useI18n } from "@composables/i18n"
|
|
||||||
import IconLucideFileKey from "~icons/lucide/file-key"
|
|
||||||
import IconLucideFileBadge from "~icons/lucide/file-badge"
|
|
||||||
import { useService } from "dioc/vue"
|
import { useService } from "dioc/vue"
|
||||||
import {
|
import { useI18n } from "@composables/i18n"
|
||||||
RequestDef,
|
import { useToast } from "@composables/toast"
|
||||||
AgentInterceptorService,
|
import { useCertificatePicker } from "@composables/picker"
|
||||||
} from "~/platform/std/interceptors/agent"
|
import { KernelInterceptorAgentStore } from "~/platform/std/kernel-interceptors/agent/store"
|
||||||
import { syncRef } from "@vueuse/core"
|
|
||||||
|
|
||||||
type RequestProxyInfo = RequestDef["proxy"]
|
import IconTrash from "~icons/lucide/trash"
|
||||||
|
import IconCheckCircle from "~icons/lucide/check-circle"
|
||||||
|
import IconCircle from "~icons/lucide/circle"
|
||||||
|
import IconFile from "~icons/lucide/file"
|
||||||
|
import IconPlus from "~icons/lucide/plus"
|
||||||
|
import IconEye from "~icons/lucide/eye"
|
||||||
|
import IconEyeOff from "~icons/lucide/eye-off"
|
||||||
|
import IconSettings from "~icons/lucide/settings"
|
||||||
|
import IconFileKey from "~icons/lucide/file-key"
|
||||||
|
import IconFileBadge from "~icons/lucide/file-badge"
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const store = useService(KernelInterceptorAgentStore)
|
||||||
|
|
||||||
const agentInterceptorService = useService(AgentInterceptorService)
|
const maskedAuthKey = ref("")
|
||||||
|
|
||||||
const allowSSLVerification = agentInterceptorService.validateCerts
|
const selectedDomain = ref("*")
|
||||||
|
const domainSettings = reactive<Record<string, any>>({})
|
||||||
|
const showDomainModal = ref(false)
|
||||||
|
const showProxyPassword = ref(false)
|
||||||
|
const newDomain = ref("")
|
||||||
|
const domains = ref<string[]>(store.getDomains())
|
||||||
|
|
||||||
const showCACertificatesModal = ref(false)
|
const showClientCertModal = ref(false)
|
||||||
const showClientCertificatesModal = ref(false)
|
const showCACertModal = ref(false)
|
||||||
|
const isClientCertIncomplete = computed(() => {
|
||||||
|
const client =
|
||||||
|
domainSettings[selectedDomain.value]?.security?.certificates?.client
|
||||||
|
if (!client) return false
|
||||||
|
|
||||||
const allowProxy = ref(false)
|
return client.kind === "pem"
|
||||||
const proxyURL = ref("")
|
? !client.cert || !client.key
|
||||||
|
: !client.data || !client.password
|
||||||
|
})
|
||||||
|
|
||||||
const proxyInfo = computed<RequestProxyInfo>({
|
const {
|
||||||
get() {
|
certType,
|
||||||
if (allowProxy.value) {
|
pfxPassword,
|
||||||
return {
|
pickPEMCertificate,
|
||||||
url: proxyURL.value,
|
pickPEMKey,
|
||||||
}
|
pickPFXCertificate,
|
||||||
|
pickCACertificate,
|
||||||
|
} = useCertificatePicker({
|
||||||
|
async onPEMCertChange(file) {
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const cert = {
|
||||||
|
include: true,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
content: new Uint8Array(await file.arrayBuffer()),
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
client: {
|
||||||
|
kind: "pem",
|
||||||
|
cert,
|
||||||
|
key: domainSettings[selectedDomain.value]?.security?.certificates
|
||||||
|
?.client?.key,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
set(newData) {
|
async onPEMKeyChange(file) {
|
||||||
if (newData) {
|
if (!file) return
|
||||||
allowProxy.value = true
|
|
||||||
proxyURL.value = newData.url
|
const key = {
|
||||||
} else {
|
include: true,
|
||||||
allowProxy.value = false
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
content: new Uint8Array(await file.arrayBuffer()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
client: {
|
||||||
|
kind: "pem",
|
||||||
|
key,
|
||||||
|
cert: domainSettings[selectedDomain.value]?.security?.certificates
|
||||||
|
?.client?.cert,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async onPFXChange(file) {
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
include: true,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
content: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
client: {
|
||||||
|
kind: "pfx",
|
||||||
|
data,
|
||||||
|
password: pfxPassword.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
async onCACertAdd(file) {
|
||||||
|
const cert = {
|
||||||
|
include: true,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
content: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentCerts =
|
||||||
|
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
ca: [...currentCerts, cert],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
syncRef(agentInterceptorService.proxyInfo, proxyInfo, { direction: "both" })
|
const selectedDomainDisplay = computed(() =>
|
||||||
|
selectedDomain.value === "*"
|
||||||
|
? t("settings.global_defaults")
|
||||||
|
: selectedDomain.value
|
||||||
|
)
|
||||||
|
|
||||||
|
function addDomain() {
|
||||||
|
if (newDomain.value) {
|
||||||
|
const domain = newDomain.value.toLowerCase()
|
||||||
|
store.saveDomainSettings(domain, { version: "v1" })
|
||||||
|
domains.value.push(domain)
|
||||||
|
newDomain.value = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDomain(domain: string) {
|
||||||
|
store.clearDomainSettings(domain)
|
||||||
|
domains.value = domains.value.filter((d) => d !== domain)
|
||||||
|
if (selectedDomain.value === domain) {
|
||||||
|
selectedDomain.value = "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDomain(domain: string) {
|
||||||
|
selectedDomain.value = domain
|
||||||
|
if (!domainSettings[domain]) {
|
||||||
|
const settings = store.getDomainSettings(domain)
|
||||||
|
domainSettings[domain] = settings
|
||||||
|
}
|
||||||
|
showDomainModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDomainSettings(newSettings: any) {
|
||||||
|
const domain = selectedDomain.value
|
||||||
|
if (!domainSettings[domain]) {
|
||||||
|
domainSettings[domain] = { version: "v1" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSettings = domainSettings[domain]
|
||||||
|
|
||||||
|
domainSettings[domain] = {
|
||||||
|
...currentSettings,
|
||||||
|
...newSettings,
|
||||||
|
security: {
|
||||||
|
...currentSettings?.security,
|
||||||
|
...newSettings.security,
|
||||||
|
certificates: {
|
||||||
|
...currentSettings?.security?.certificates,
|
||||||
|
...newSettings.security?.certificates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store.saveDomainSettings(domain, domainSettings[domain])
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVerifyHost() {
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
verifyHost: !domainSettings[selectedDomain.value]?.security?.verifyHost,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVerifyPeer() {
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
verifyPeer: !domainSettings[selectedDomain.value]?.security?.verifyPeer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleProxy() {
|
||||||
|
updateDomainSettings({
|
||||||
|
proxy: domainSettings[selectedDomain.value]?.proxy
|
||||||
|
? undefined
|
||||||
|
: { url: "" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProxyUrl(value: string) {
|
||||||
|
const current = domainSettings[selectedDomain.value]?.proxy
|
||||||
|
updateDomainSettings({
|
||||||
|
proxy: { ...current, url: value },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProxyUsername(value: string) {
|
||||||
|
const current = domainSettings[selectedDomain.value]?.proxy
|
||||||
|
updateDomainSettings({
|
||||||
|
proxy: {
|
||||||
|
...current,
|
||||||
|
auth: {
|
||||||
|
...(current?.auth ?? {}),
|
||||||
|
username: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProxyPassword(value: string) {
|
||||||
|
const current = domainSettings[selectedDomain.value]?.proxy
|
||||||
|
updateDomainSettings({
|
||||||
|
proxy: {
|
||||||
|
...current,
|
||||||
|
auth: {
|
||||||
|
...(current?.auth ?? {}),
|
||||||
|
password: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePFXPassword(value: string) {
|
||||||
|
const currentClient =
|
||||||
|
domainSettings[selectedDomain.value]?.security?.certificates?.client
|
||||||
|
if (currentClient?.kind !== "pfx") return
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
client: {
|
||||||
|
...currentClient,
|
||||||
|
password: value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCACertFromStore(index: number) {
|
||||||
|
const currentCerts =
|
||||||
|
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
|
||||||
|
const newCerts = [...currentCerts]
|
||||||
|
newCerts[index] = {
|
||||||
|
...newCerts[index],
|
||||||
|
include: !newCerts[index].include,
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
ca: newCerts,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCACert(index: number) {
|
||||||
|
const currentCerts =
|
||||||
|
domainSettings[selectedDomain.value]?.security?.certificates?.ca || []
|
||||||
|
const newCerts = [...currentCerts]
|
||||||
|
newCerts.splice(index, 1)
|
||||||
|
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
ca: newCerts.length ? newCerts : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearClientCerts() {
|
||||||
|
updateDomainSettings({
|
||||||
|
security: {
|
||||||
|
certificates: {
|
||||||
|
client: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMaskedAuthKey() {
|
||||||
|
if (!store.authKey.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const registration = await store.fetchRegistrationInfo()
|
||||||
|
maskedAuthKey.value = registration.auth_key_hash
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(t("settings.registration_fetch_failed"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const initialSettings = store.getDomainSettings("*")
|
||||||
|
domainSettings["*"] = initialSettings
|
||||||
|
|
||||||
|
if (store.authKey.value) {
|
||||||
|
await updateMaskedAuthKey()
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -12,29 +12,25 @@
|
||||||
{{ t("settings.extension_ver_not_reported") }}
|
{{ t("settings.extension_ver_not_reported") }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col space-y-2 py-4">
|
<div class="flex gap-2 py-2 w-fit">
|
||||||
<span>
|
<HoppSmartItem
|
||||||
<HoppSmartItem
|
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
||||||
to="https://chrome.google.com/webstore/detail/hoppscotch-browser-extens/amknoiejhlmhancpahfcfcfhllgkpbld"
|
blank
|
||||||
blank
|
:icon="IconChrome"
|
||||||
:icon="IconChrome"
|
label="Chrome"
|
||||||
label="Chrome"
|
:info-icon="hasChromeExtInstalled ? IconCheckCircle : undefined"
|
||||||
:info-icon="hasChromeExtInstalled ? IconCheckCircle : null"
|
:active-info-icon="hasChromeExtInstalled"
|
||||||
:active-info-icon="hasChromeExtInstalled"
|
outline
|
||||||
outline
|
/>
|
||||||
/>
|
<HoppSmartItem
|
||||||
</span>
|
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
||||||
<span>
|
blank
|
||||||
<HoppSmartItem
|
:icon="IconFirefox"
|
||||||
to="https://addons.mozilla.org/en-US/firefox/addon/hoppscotch"
|
label="Firefox"
|
||||||
blank
|
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : undefined"
|
||||||
:icon="IconFirefox"
|
:active-info-icon="hasFirefoxExtInstalled"
|
||||||
label="Firefox"
|
outline
|
||||||
:info-icon="hasFirefoxExtInstalled ? IconCheckCircle : null"
|
/>
|
||||||
:active-info-icon="hasFirefoxExtInstalled"
|
|
||||||
outline
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
642
packages/hoppscotch-common/src/components/settings/Native.vue
Normal file
642
packages/hoppscotch-common/src/components/settings/Native.vue
Normal 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>
|
||||||
|
|
@ -10,13 +10,14 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-2 py-4">
|
<div class="flex items-center space-x-2 py-4">
|
||||||
<HoppSmartInput
|
<HoppSmartInput
|
||||||
v-model="PROXY_URL"
|
v-model="proxyUrl"
|
||||||
:autofocus="false"
|
:autofocus="false"
|
||||||
styles="flex-1"
|
styles="flex-1"
|
||||||
placeholder=" "
|
:placeholder="' '"
|
||||||
:label="t('settings.proxy_url')"
|
:label="t('settings.proxy_url')"
|
||||||
input-styles="input floating-input"
|
input-styles="input floating-input"
|
||||||
:disabled="!proxyEnabled"
|
:disabled="!enabled"
|
||||||
|
@change="updateProxyUrl"
|
||||||
/>
|
/>
|
||||||
<HoppButtonSecondary
|
<HoppButtonSecondary
|
||||||
v-tippy="{ theme: 'tooltip' }"
|
v-tippy="{ theme: 'tooltip' }"
|
||||||
|
|
@ -24,50 +25,61 @@
|
||||||
:icon="clearIcon"
|
:icon="clearIcon"
|
||||||
outline
|
outline
|
||||||
class="rounded"
|
class="rounded"
|
||||||
@click="resetProxy"
|
@click="resetSettings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch } from "vue"
|
||||||
import { refAutoReset } from "@vueuse/core"
|
import { refAutoReset } from "@vueuse/core"
|
||||||
|
|
||||||
|
import { useService } from "dioc/vue"
|
||||||
|
|
||||||
import { useI18n } from "~/composables/i18n"
|
import { useI18n } from "~/composables/i18n"
|
||||||
import { useSetting } from "~/composables/settings"
|
import { useToast } from "~/composables/toast"
|
||||||
|
import { useReadonlyStream } from "~/composables/stream"
|
||||||
|
import { getDefaultProxyUrl } from "~/helpers/proxyUrl"
|
||||||
|
import { platform } from "~/platform"
|
||||||
|
|
||||||
|
import { KernelInterceptorProxyStore } from "~/platform/std/kernel-interceptors/proxy/store"
|
||||||
|
import { ProxyKernelInterceptorService } from "~/platform/std/kernel-interceptors/proxy/index"
|
||||||
|
|
||||||
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
|
||||||
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
import IconRotateCCW from "~icons/lucide/rotate-ccw"
|
||||||
import IconCheck from "~icons/lucide/check"
|
import IconCheck from "~icons/lucide/check"
|
||||||
import { useToast } from "~/composables/toast"
|
|
||||||
import { computed, watch } from "vue"
|
|
||||||
import { useService } from "dioc/vue"
|
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
|
||||||
import { proxyInterceptor } from "~/platform/std/interceptors/proxy"
|
|
||||||
import { useReadonlyStream } from "~/composables/stream"
|
|
||||||
import { platform } from "~/platform"
|
|
||||||
import { getDefaultProxyUrl } from "~/helpers/proxyUrl"
|
|
||||||
|
|
||||||
const t = useI18n()
|
const t = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const interceptorService = useService(InterceptorService)
|
const store = useService(KernelInterceptorProxyStore)
|
||||||
|
const interceptorService = useService(KernelInterceptorService)
|
||||||
|
const proxyInterceptorService = useService(ProxyKernelInterceptorService)
|
||||||
|
|
||||||
const PROXY_URL = useSetting("PROXY_URL")
|
const proxyUrl = ref("")
|
||||||
|
|
||||||
const currentUser = useReadonlyStream(
|
const currentUser = useReadonlyStream(
|
||||||
platform.auth.getCurrentUserStream(),
|
platform.auth.getCurrentUserStream(),
|
||||||
platform.auth.getCurrentUser()
|
platform.auth.getCurrentUser()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function updateProxyUrl() {
|
||||||
|
await store.updateSettings({ proxyUrl: proxyUrl.value })
|
||||||
|
toast.success(t("state.saved"))
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => currentUser.value,
|
() => currentUser.value,
|
||||||
async () => {
|
async () => {
|
||||||
if (!currentUser.value) {
|
if (!currentUser.value) {
|
||||||
PROXY_URL.value = await getDefaultProxyUrl()
|
proxyUrl.value = await getDefaultProxyUrl()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const proxyEnabled = computed(
|
|
||||||
() =>
|
const enabled = computed(
|
||||||
interceptorService.currentInterceptorID.value ===
|
() => interceptorService.getCurrentId() === proxyInterceptorService.id
|
||||||
proxyInterceptor.interceptorID
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
|
const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
|
||||||
|
|
@ -75,9 +87,16 @@ const clearIcon = refAutoReset<typeof IconRotateCCW | typeof IconCheck>(
|
||||||
1000
|
1000
|
||||||
)
|
)
|
||||||
|
|
||||||
const resetProxy = async () => {
|
async function resetSettings() {
|
||||||
PROXY_URL.value = await getDefaultProxyUrl()
|
await store.resetSettings()
|
||||||
|
const settings = store.getSettings()
|
||||||
|
proxyUrl.value = settings.proxyUrl
|
||||||
clearIcon.value = IconCheck
|
clearIcon.value = IconCheck
|
||||||
toast.success(`${t("state.cleared")}`)
|
toast.success(t("state.cleared"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const settings = store.getSettings()
|
||||||
|
proxyUrl.value = settings.proxyUrl
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export function useDownloadResponse(
|
||||||
const dataToWrite = responseBody.value
|
const dataToWrite = responseBody.value
|
||||||
|
|
||||||
// TODO: Look at the mime type and determine extension ?
|
// TODO: Look at the mime type and determine extension ?
|
||||||
const result = await platform.io.saveFileWithDialog({
|
const result = await platform.kernelIO.saveFileWithDialog({
|
||||||
data: dataToWrite,
|
data: dataToWrite,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
suggestedFilename: filename,
|
suggestedFilename: filename,
|
||||||
|
|
|
||||||
138
packages/hoppscotch-common/src/composables/picker.ts
Normal file
138
packages/hoppscotch-common/src/composables/picker.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,14 +5,18 @@ import { getService } from "~/modules/dioc"
|
||||||
import { PersistenceService } from "~/services/persistence"
|
import { PersistenceService } from "~/services/persistence"
|
||||||
import { version as hoppscotchCommonPkgVersion } from "./../../package.json"
|
import { version as hoppscotchCommonPkgVersion } from "./../../package.json"
|
||||||
|
|
||||||
export function useWhatsNewDialog() {
|
export async function useWhatsNewDialog() {
|
||||||
const persistenceService = getService(PersistenceService)
|
const persistenceService = getService(PersistenceService)
|
||||||
|
|
||||||
const versionFromLocalStorage = persistenceService.getLocalConfig("hopp_v")
|
const versionFromLocalStorage =
|
||||||
|
await persistenceService.getLocalConfig("hopp_v")
|
||||||
|
|
||||||
// Set new entry `hopp_v` under `localStorage` if not present
|
// Set new entry `hopp_v` under `localStorage` if not present
|
||||||
if (!versionFromLocalStorage) {
|
if (!versionFromLocalStorage) {
|
||||||
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
|
await persistenceService.setLocalConfig(
|
||||||
|
"hopp_v",
|
||||||
|
hoppscotchCommonPkgVersion
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,7 +57,7 @@ export function useWhatsNewDialog() {
|
||||||
}, 10000)
|
}, 10000)
|
||||||
}
|
}
|
||||||
|
|
||||||
persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
|
await persistenceService.setLocalConfig("hopp_v", hoppscotchCommonPkgVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getReleaseNotes(v: string): Promise<string | undefined> {
|
async function getReleaseNotes(v: string): Promise<string | undefined> {
|
||||||
|
|
|
||||||
|
|
@ -261,9 +261,8 @@ export function runRESTRequest$(
|
||||||
variables: finalEnvsWithNonEmptyValues,
|
variables: finalEnvsWithNonEmptyValues,
|
||||||
})
|
})
|
||||||
|
|
||||||
const [stream, cancelRun] = createRESTNetworkRequestStream(
|
const [stream, cancelRun] =
|
||||||
await effectiveRequest
|
await createRESTNetworkRequestStream(effectiveRequest)
|
||||||
)
|
|
||||||
cancelFunc = cancelRun
|
cancelFunc = cancelRun
|
||||||
|
|
||||||
const subscription = stream
|
const subscription = stream
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export type HoppAction =
|
||||||
| "response.save" // Save response
|
| "response.save" // Save response
|
||||||
| "response.save-as-example" // Save response as example
|
| "response.save-as-example" // Save response as example
|
||||||
| "modals.login.toggle" // Login to Hoppscotch
|
| "modals.login.toggle" // Login to Hoppscotch
|
||||||
|
| "modals.instance-switcher.toggle" // Switch Hoppscotch instances (self-hosted)
|
||||||
| "history.clear" // Clear REST History
|
| "history.clear" // Clear REST History
|
||||||
| "user.login" // Login to Hoppscotch
|
| "user.login" // Login to Hoppscotch
|
||||||
| "user.logout" // Log out of Hoppscotch
|
| "user.logout" // Log out of Hoppscotch
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { md5 } from "js-md5"
|
||||||
|
|
||||||
import { getService } from "~/modules/dioc"
|
import { getService } from "~/modules/dioc"
|
||||||
import { getI18n } from "~/modules/i18n"
|
import { getI18n } from "~/modules/i18n"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
|
||||||
export interface DigestAuthParams {
|
export interface DigestAuthParams {
|
||||||
username: string
|
username: string
|
||||||
|
|
@ -85,11 +85,15 @@ export async function fetchInitialDigestAuthInfo(
|
||||||
const t = getI18n()
|
const t = getI18n()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const service = getService(InterceptorService)
|
const interceptorService = getService(KernelInterceptorService)
|
||||||
const initialResponse = await service.runRequest({
|
const exec = await interceptorService.execute({
|
||||||
|
id: Date.now(),
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
}).response
|
version: "HTTP/1.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialResponse = await exec.response
|
||||||
|
|
||||||
if (E.isLeft(initialResponse)) {
|
if (E.isLeft(initialResponse)) {
|
||||||
const initialFetchFailureReason =
|
const initialFetchFailureReason =
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
import * as O from "fp-ts/Option"
|
import * as O from "fp-ts/Option"
|
||||||
import { flow } from "fp-ts/function"
|
import * as E from "fp-ts/Either"
|
||||||
|
import { pipe, flow } from "fp-ts/function"
|
||||||
|
|
||||||
|
import { MediaType, RelayResponseBody } from "@hoppscotch/kernel"
|
||||||
|
|
||||||
|
import { decodeToString } from "~/helpers/functional/parse"
|
||||||
|
|
||||||
type SafeParseJSON = {
|
type SafeParseJSON = {
|
||||||
(str: string, convertToArray: true): O.Option<Array<unknown>>
|
(str: string, convertToArray: true): O.Option<Array<unknown>>
|
||||||
|
|
@ -26,3 +31,21 @@ export const safeParseJSON: SafeParseJSON = (str, convertToArray = false) =>
|
||||||
* @returns If string is a JSON string
|
* @returns If string is a JSON string
|
||||||
*/
|
*/
|
||||||
export const isJSON = flow(safeParseJSON, O.isSome)
|
export const isJSON = flow(safeParseJSON, O.isSome)
|
||||||
|
|
||||||
|
export const parseBytesToJSON = <T>(content: Uint8Array): O.Option<T> =>
|
||||||
|
pipe(
|
||||||
|
content,
|
||||||
|
decodeToString,
|
||||||
|
E.chain(parseJSONAs<T>),
|
||||||
|
E.fold(() => O.none, O.some)
|
||||||
|
)
|
||||||
|
|
||||||
|
export const parseJSONAs = <T>(str: string): E.Either<Error, T> =>
|
||||||
|
E.tryCatch(() => JSON.parse(str) as T, E.toError)
|
||||||
|
|
||||||
|
export const parseBodyAsJSON = <T>(body: RelayResponseBody): O.Option<T> =>
|
||||||
|
pipe(
|
||||||
|
O.fromNullable(body.mediaType),
|
||||||
|
O.filter((type) => type.includes(MediaType.APPLICATION_JSON)),
|
||||||
|
O.chain(() => parseBytesToJSON<T>(body.body))
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
|
@ -25,9 +25,13 @@ import { getI18n } from "~/modules/i18n"
|
||||||
|
|
||||||
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
|
import { addGraphqlHistoryEntry, makeGQLHistoryEntry } from "~/newstore/history"
|
||||||
|
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
import { GQLTabService } from "~/services/tab/graphql"
|
import { GQLTabService } from "~/services/tab/graphql"
|
||||||
|
|
||||||
|
import { MediaType, content, Method, RelayRequest } from "@hoppscotch/kernel"
|
||||||
|
import { GQLRequest } from "~/helpers/kernel/gql/request"
|
||||||
|
import { GQLResponse } from "~/helpers/kernel/gql/response"
|
||||||
|
|
||||||
const GQL_SCHEMA_POLL_INTERVAL = 7000
|
const GQL_SCHEMA_POLL_INTERVAL = 7000
|
||||||
|
|
||||||
type ConnectionRequestOptions = {
|
type ConnectionRequestOptions = {
|
||||||
|
|
@ -111,49 +115,44 @@ export const connection = reactive<Connection>({
|
||||||
})
|
})
|
||||||
|
|
||||||
export const schema = computed(() => connection.schema)
|
export const schema = computed(() => connection.schema)
|
||||||
export const subscriptionState = computed(() => {
|
export const subscriptionState = computed(() =>
|
||||||
return connection.subscriptionState.get(currentTabID.value)
|
connection.subscriptionState.get(currentTabID.value)
|
||||||
})
|
)
|
||||||
|
|
||||||
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
|
export const gqlMessageEvent = ref<GQLResponseEvent | "reset">()
|
||||||
|
|
||||||
export const schemaString = computed(() => {
|
export const schemaString = computed(() => {
|
||||||
if (!connection.schema) return ""
|
if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
|
||||||
|
return ""
|
||||||
return printSchema(connection.schema)
|
return printSchema(connection.schema)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const queryFields = computed(() => {
|
export const queryFields = computed(() => {
|
||||||
if (!connection.schema) return []
|
if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
|
||||||
|
return []
|
||||||
const fields = connection.schema.getQueryType()?.getFields()
|
const fields = connection.schema.getQueryType()?.getFields()
|
||||||
if (!fields) return []
|
return fields ? Object.values(fields) : []
|
||||||
|
|
||||||
return Object.values(fields)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const mutationFields = computed(() => {
|
export const mutationFields = computed(() => {
|
||||||
if (!connection.schema) return []
|
if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
|
||||||
|
return []
|
||||||
const fields = connection.schema.getMutationType()?.getFields()
|
const fields = connection.schema.getMutationType()?.getFields()
|
||||||
if (!fields) return []
|
return fields ? Object.values(fields) : []
|
||||||
|
|
||||||
return Object.values(fields)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const subscriptionFields = computed(() => {
|
export const subscriptionFields = computed(() => {
|
||||||
if (!connection.schema) return []
|
if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
|
||||||
|
return []
|
||||||
const fields = connection.schema.getSubscriptionType()?.getFields()
|
const fields = connection.schema.getSubscriptionType()?.getFields()
|
||||||
if (!fields) return []
|
return fields ? Object.values(fields) : []
|
||||||
|
|
||||||
return Object.values(fields)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const graphqlTypes = computed(() => {
|
export const graphqlTypes = computed(() => {
|
||||||
if (!connection.schema) return []
|
if (!connection.schema || !(connection.schema instanceof GraphQLSchema))
|
||||||
|
return []
|
||||||
|
|
||||||
const typeMap = connection.schema.getTypeMap()
|
const typeMap = connection.schema.getTypeMap()
|
||||||
|
|
||||||
const queryTypeName = connection.schema.getQueryType()?.name ?? ""
|
const queryTypeName = connection.schema.getQueryType()?.name ?? ""
|
||||||
const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
|
const mutationTypeName = connection.schema.getMutationType()?.name ?? ""
|
||||||
const subscriptionTypeName =
|
const subscriptionTypeName =
|
||||||
|
|
@ -193,7 +192,6 @@ export const connect = async (
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
await getSchema(options)
|
await getSchema(options)
|
||||||
// polling for schema
|
|
||||||
if (connection.state !== "CONNECTED") connection.state = "CONNECTED"
|
if (connection.state !== "CONNECTED") connection.state = "CONNECTED"
|
||||||
timeoutSubscription = setTimeout(() => {
|
timeoutSubscription = setTimeout(() => {
|
||||||
poll()
|
poll()
|
||||||
|
|
@ -201,7 +199,6 @@ export const connect = async (
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
connection.state = "ERROR"
|
connection.state = "ERROR"
|
||||||
|
|
||||||
// Show an error toast if the introspection attempt failed and not while sending a request
|
|
||||||
if (!isRunGQLOperation) {
|
if (!isRunGQLOperation) {
|
||||||
toast.error(t("graphql.connection_error_http"))
|
toast.error(t("graphql.connection_error_http"))
|
||||||
}
|
}
|
||||||
|
|
@ -232,10 +229,6 @@ export const reset = () => {
|
||||||
|
|
||||||
const getSchema = async (options: ConnectionRequestOptions) => {
|
const getSchema = async (options: ConnectionRequestOptions) => {
|
||||||
try {
|
try {
|
||||||
const introspectionQuery = JSON.stringify({
|
|
||||||
query: getIntrospectionQuery(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { url, request, inheritedHeaders, inheritedAuth } = options
|
const { url, request, inheritedHeaders, inheritedAuth } = options
|
||||||
|
|
||||||
const headers = request?.headers || []
|
const headers = request?.headers || []
|
||||||
|
|
@ -271,62 +264,60 @@ const getSchema = async (options: ConnectionRequestOptions) => {
|
||||||
.filter((item) => item.active && item.key !== "")
|
.filter((item) => item.active && item.key !== "")
|
||||||
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
||||||
|
|
||||||
const reqOptions = {
|
const kernelRequest: RelayRequest = {
|
||||||
method: "POST",
|
id: Date.now(),
|
||||||
url: options.url,
|
url: options.url,
|
||||||
|
method: "POST" as Method,
|
||||||
|
version: "HTTP/1.1",
|
||||||
headers: {
|
headers: {
|
||||||
...finalHeaders,
|
...finalHeaders,
|
||||||
"content-type": "application/json",
|
"content-type": "application/json",
|
||||||
},
|
},
|
||||||
data: introspectionQuery,
|
content: content.json(
|
||||||
|
{ query: getIntrospectionQuery() },
|
||||||
|
MediaType.APPLICATION_JSON
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
const interceptorService = getService(InterceptorService)
|
const kernelInterceptorService = getService(KernelInterceptorService)
|
||||||
|
const { response } = kernelInterceptorService.execute(kernelRequest)
|
||||||
|
|
||||||
const res = await interceptorService.runRequest(reqOptions).response
|
const res = await response
|
||||||
|
|
||||||
if (E.isLeft(res)) {
|
if (E.isLeft(res)) {
|
||||||
connection.state = "ERROR"
|
connection.state = "ERROR"
|
||||||
|
|
||||||
if (
|
if (res.left !== "cancellation" && typeof res.left === "object") {
|
||||||
res.left !== "cancellation" &&
|
|
||||||
res.left.error === "NO_PW_EXT_HOOK" &&
|
|
||||||
res.left.humanMessage
|
|
||||||
) {
|
|
||||||
connection.error = {
|
connection.error = {
|
||||||
type: res.left.error,
|
type: res.left.error?.kind || "error",
|
||||||
message: (t: ReturnType<typeof getI18n>) =>
|
message: (t: ReturnType<typeof getI18n>) => {
|
||||||
res.left.humanMessage.description(t),
|
if (res.left !== "cancellation" && typeof res.left === "object") {
|
||||||
|
return (
|
||||||
|
res.left.humanMessage?.description(t) ||
|
||||||
|
t("graphql.connection_error_http")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
},
|
||||||
component: res.left.component,
|
component: res.left.component,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(res.left.toString())
|
throw new Error(
|
||||||
}
|
typeof res.left === "string" ? res.left : res.left.error.message
|
||||||
|
)
|
||||||
if (res.right.status !== 200) {
|
|
||||||
connection.state = "ERROR"
|
|
||||||
connection.error = {
|
|
||||||
type: "HTTP_ERROR",
|
|
||||||
message: (t: ReturnType<typeof getI18n>) =>
|
|
||||||
t("graphql.connection_error_http"),
|
|
||||||
component: undefined,
|
|
||||||
}
|
|
||||||
throw new Error("Failed to fetch schema. Status: " + res.right.status)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = res.right
|
const data = res.right
|
||||||
|
|
||||||
// HACK : Temporary trailing null character issue from the extension fix
|
const decoder = new TextDecoder("utf-8")
|
||||||
const response = new TextDecoder("utf-8")
|
const responseText = decoder.decode(data.body.body)
|
||||||
.decode(data.data as any)
|
|
||||||
.replace(/\0+$/, "")
|
|
||||||
|
|
||||||
const introspectResponse = JSON.parse(response)
|
const introspectResponse = JSON.parse(responseText)
|
||||||
|
|
||||||
const schema = buildClientSchema(introspectResponse.data)
|
const schemaData = buildClientSchema(introspectResponse.data)
|
||||||
|
|
||||||
connection.schema = schema
|
connection.schema = schemaData
|
||||||
connection.error = null
|
connection.error = null
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|
@ -380,6 +371,15 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
|
||||||
|
|
||||||
const { authHeaders, authParams } = await generateAuthHeader(url, auth)
|
const { authHeaders, authParams } = await generateAuthHeader(url, auth)
|
||||||
|
|
||||||
|
let finalUrl = url
|
||||||
|
if (Object.keys(authParams).length > 0) {
|
||||||
|
const urlObj = new URL(url)
|
||||||
|
for (const [key, value] of Object.entries(authParams)) {
|
||||||
|
urlObj.searchParams.append(key, value)
|
||||||
|
}
|
||||||
|
finalUrl = urlObj.toString()
|
||||||
|
}
|
||||||
|
|
||||||
runHeaders.forEach((header) => {
|
runHeaders.forEach((header) => {
|
||||||
if (header.active && header.key !== "") {
|
if (header.active && header.key !== "") {
|
||||||
finalHeaders[header.key] = header.value
|
finalHeaders[header.key] = header.value
|
||||||
|
|
@ -387,74 +387,91 @@ export const runGQLOperation = async (options: RunQueryOptions) => {
|
||||||
})
|
})
|
||||||
Object.assign(finalHeaders, authHeaders)
|
Object.assign(finalHeaders, authHeaders)
|
||||||
|
|
||||||
const parsedVariables = JSON.parse(variables || "{}")
|
|
||||||
|
|
||||||
const params: Record<string, string> = {}
|
|
||||||
|
|
||||||
headers
|
headers
|
||||||
.filter((item) => item.active && item.key !== "")
|
.filter((item) => item.active && item.key !== "")
|
||||||
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
.forEach(({ key, value }) => (finalHeaders[key] = value))
|
||||||
|
|
||||||
const reqOptions = {
|
const gqlRequest: HoppGQLRequest = {
|
||||||
method: "POST",
|
v: 8,
|
||||||
url,
|
name: options.name || "Untitled Request",
|
||||||
headers: {
|
url: finalUrl,
|
||||||
...finalHeaders,
|
headers: request.headers,
|
||||||
"content-type": "application/json",
|
query,
|
||||||
},
|
variables,
|
||||||
data: JSON.stringify({
|
auth: request.auth as HoppGQLAuth,
|
||||||
query,
|
|
||||||
variables: parsedVariables,
|
|
||||||
operationName,
|
|
||||||
}),
|
|
||||||
params: {
|
|
||||||
...params,
|
|
||||||
...authParams,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operationType === "subscription") {
|
if (operationType === "subscription") {
|
||||||
return runSubscription(options, finalHeaders)
|
return runSubscription(options, finalHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
const interceptorService = getService(InterceptorService)
|
try {
|
||||||
const result = await interceptorService.runRequest(reqOptions).response
|
const kernelRequest = await GQLRequest.toRequest(gqlRequest)
|
||||||
|
|
||||||
if (E.isLeft(result)) {
|
if (operationName) {
|
||||||
if (
|
if (kernelRequest.content?.kind === "json") {
|
||||||
result.left !== "cancellation" &&
|
const content = kernelRequest.content.content as any
|
||||||
result.left.error === "NO_PW_EXT_HOOK" &&
|
content.operationName = operationName
|
||||||
result.left.humanMessage
|
kernelRequest.content.content = content
|
||||||
) {
|
|
||||||
connection.error = {
|
|
||||||
type: result.left.error,
|
|
||||||
message: (t: ReturnType<typeof getI18n>) =>
|
|
||||||
result.left.humanMessage.description(t),
|
|
||||||
component: result.left.component,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(result.left.toString())
|
|
||||||
|
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" &&
|
||||||
|
typeof result.left === "object"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
result.left.humanMessage?.description(t) ||
|
||||||
|
t("graphql.operation_error")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
},
|
||||||
|
component: result.left.component,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
typeof result.left === "string"
|
||||||
|
? result.left
|
||||||
|
: result.left.error.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const relayResponse = result.right
|
||||||
|
|
||||||
|
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: "error",
|
||||||
|
error: {
|
||||||
|
type: "network_error",
|
||||||
|
message: error.message || "An unknown error occurred",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = 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+$/, "")
|
|
||||||
|
|
||||||
gqlMessageEvent.value = {
|
|
||||||
type: "response",
|
|
||||||
time: Date.now(),
|
|
||||||
operationName: operationName ?? "query",
|
|
||||||
data: responseText,
|
|
||||||
rawQuery: options,
|
|
||||||
operationType,
|
|
||||||
}
|
|
||||||
|
|
||||||
addQueryToHistory(options, responseText)
|
|
||||||
|
|
||||||
return responseText
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateAuthHeader = async (
|
const generateAuthHeader = async (
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ export const initializeDownloadFile = async (
|
||||||
|
|
||||||
const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0]
|
const fileName = name ?? url.split("/").pop()!.split("#")[0].split("?")[0]
|
||||||
|
|
||||||
const result = await platform.io.saveFileWithDialog({
|
const result = await platform.kernelIO.saveFileWithDialog({
|
||||||
data: contentsJSON,
|
data: contentsJSON,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
suggestedFilename: `${fileName}.json`,
|
suggestedFilename: `${fileName}.json`,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const exportTestResults = async (testResults: HoppTestResult) => {
|
||||||
|
|
||||||
const fileName = url.split("/").pop()!.split("#")[0].split("?")[0]
|
const fileName = url.split("/").pop()!.split("#")[0].split("?")[0]
|
||||||
|
|
||||||
const result = await platform.io.saveFileWithDialog({
|
const result = await platform.kernelIO.saveFileWithDialog({
|
||||||
data: contentsJSON,
|
data: contentsJSON,
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
suggestedFilename: `${fileName}.json`,
|
suggestedFilename: `${fileName}.json`,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,16 @@ import UrlImport from "~/components/importExport/ImportExportSteps/UrlImport.vue
|
||||||
import { defineStep } from "~/composables/step-components"
|
import { defineStep } from "~/composables/step-components"
|
||||||
|
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
|
import * as O from "fp-ts/Option"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
|
|
||||||
import { v4 as uuidv4 } from "uuid"
|
import { v4 as uuidv4 } from "uuid"
|
||||||
import { Ref } from "vue"
|
import { Ref } from "vue"
|
||||||
import { getService } from "~/modules/dioc"
|
import { getService } from "~/modules/dioc"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
import { parseBodyAsJSON } from "~/helpers/functional/json"
|
||||||
|
|
||||||
const interceptorService = getService(InterceptorService)
|
const interceptorService = getService(KernelInterceptorService)
|
||||||
|
|
||||||
export function GistSource(metadata: {
|
export function GistSource(metadata: {
|
||||||
caption: string
|
caption: string
|
||||||
|
|
@ -48,31 +50,27 @@ export function GistSource(metadata: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchGistFromUrl = async (url: string) => {
|
const fetchGistFromUrl = async (url: string) => {
|
||||||
const res = await interceptorService.runRequest({
|
const { response } = interceptorService.execute({
|
||||||
|
id: Date.now(),
|
||||||
url: `https://api.github.com/gists/${url.split("/").pop()}`,
|
url: `https://api.github.com/gists/${url.split("/").pop()}`,
|
||||||
|
method: "GET",
|
||||||
|
version: "HTTP/1.1",
|
||||||
headers: {
|
headers: {
|
||||||
Accept: "application/vnd.github.v3+json",
|
Accept: "application/vnd.github.v3+json",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await res.response
|
const res = await response
|
||||||
|
|
||||||
if (E.isLeft(response)) {
|
if (E.isLeft(res)) {
|
||||||
return E.left("REQUEST_FAILED")
|
return E.left("REQUEST_FAILED")
|
||||||
}
|
}
|
||||||
|
|
||||||
// convert ArrayBuffer to string
|
const responsePayload = parseBodyAsJSON<unknown>(res.right.body)
|
||||||
if (!(response.right.data instanceof ArrayBuffer)) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
if (O.isSome(responsePayload)) {
|
||||||
|
return E.right(responsePayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return E.left("REQUEST_FAILED")
|
||||||
return E.right(
|
|
||||||
JSON.parse(
|
|
||||||
InterceptorService.convertArrayBufferToString(response.right.data)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} catch (e) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
286
packages/hoppscotch-common/src/helpers/kernel/common/auth.ts
Normal file
286
packages/hoppscotch-common/src/helpers/kernel/common/auth.ts
Normal 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
|
||||||
|
)
|
||||||
192
packages/hoppscotch-common/src/helpers/kernel/common/content.ts
Normal file
192
packages/hoppscotch-common/src/helpers/kernel/common/content.ts
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { transformContent } from "./content"
|
||||||
|
export { transformAuth } from "./auth"
|
||||||
48
packages/hoppscotch-common/src/helpers/kernel/gql/request.ts
Normal file
48
packages/hoppscotch-common/src/helpers/kernel/gql/request.ts
Normal 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { RESTRequest } from "./request"
|
||||||
|
export { RESTResponse } from "./response"
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -1,49 +1,16 @@
|
||||||
import { AxiosRequestConfig } from "axios"
|
import * as TE from "fp-ts/TaskEither"
|
||||||
import { BehaviorSubject, Observable } from "rxjs"
|
import { BehaviorSubject, Observable } from "rxjs"
|
||||||
import { cloneDeep } from "lodash-es"
|
import { cloneDeep } from "lodash-es"
|
||||||
import * as E from "fp-ts/Either"
|
|
||||||
import * as TE from "fp-ts/TaskEither"
|
|
||||||
import { HoppRESTResponse } from "./types/HoppRESTResponse"
|
import { HoppRESTResponse } from "./types/HoppRESTResponse"
|
||||||
import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL"
|
import { EffectiveHoppRESTRequest } from "./utils/EffectiveURL"
|
||||||
import { getService } from "~/modules/dioc"
|
import { getService } from "~/modules/dioc"
|
||||||
import {
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
InterceptorService,
|
import { RESTRequest, RESTResponse } from "~/helpers/kernel/rest"
|
||||||
NetworkResponse,
|
import { RelayError } from "@hoppscotch/kernel"
|
||||||
} from "~/services/interceptor.service"
|
|
||||||
|
|
||||||
export type NetworkStrategy = (
|
export type NetworkStrategy = (
|
||||||
req: AxiosRequestConfig
|
req: EffectiveHoppRESTRequest
|
||||||
) => TE.TaskEither<any, NetworkResponse>
|
) => TE.TaskEither<RelayError, HoppRESTResponse>
|
||||||
|
|
||||||
function processResponse(
|
|
||||||
res: NetworkResponse,
|
|
||||||
req: EffectiveHoppRESTRequest,
|
|
||||||
backupTimeStart: number,
|
|
||||||
backupTimeEnd: number,
|
|
||||||
successState: HoppRESTResponse["type"]
|
|
||||||
) {
|
|
||||||
const contentLength = res.headers["content-length"]
|
|
||||||
? parseInt(res.headers["content-length"])
|
|
||||||
: (res.data as ArrayBuffer).byteLength
|
|
||||||
return <HoppRESTResponse>{
|
|
||||||
type: successState,
|
|
||||||
statusCode: res.status,
|
|
||||||
statusText: res.statusText,
|
|
||||||
body: res.data,
|
|
||||||
// If multi headers are present, then we can just use that, else fallback to Axios type
|
|
||||||
headers:
|
|
||||||
res.additional?.multiHeaders ??
|
|
||||||
Object.keys(res.headers).map((x) => ({
|
|
||||||
key: x,
|
|
||||||
value: res.headers[x],
|
|
||||||
})),
|
|
||||||
meta: {
|
|
||||||
responseSize: contentLength,
|
|
||||||
responseDuration: backupTimeEnd - backupTimeStart,
|
|
||||||
},
|
|
||||||
req,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRESTNetworkRequestStream(
|
export function createRESTNetworkRequestStream(
|
||||||
request: EffectiveHoppRESTRequest
|
request: EffectiveHoppRESTRequest
|
||||||
|
|
@ -55,52 +22,58 @@ export function createRESTNetworkRequestStream(
|
||||||
|
|
||||||
const req = cloneDeep(request)
|
const req = cloneDeep(request)
|
||||||
|
|
||||||
const headers = req.effectiveFinalHeaders.reduce((acc, { key, value }) => {
|
console.info("[helpers/network]: req", req)
|
||||||
return Object.assign(acc, { [key]: value })
|
|
||||||
}, {})
|
|
||||||
|
|
||||||
const params = new URLSearchParams()
|
const execResult = RESTRequest.toRequest(req).then((kernelRequest) => {
|
||||||
for (const param of req.effectiveFinalParams) {
|
console.info("[helpers/network]: kernelRequest", kernelRequest)
|
||||||
params.append(param.key, param.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const backupTimeStart = Date.now()
|
if (!kernelRequest) {
|
||||||
|
response.next({
|
||||||
const service = getService(InterceptorService)
|
type: "network_fail",
|
||||||
|
|
||||||
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,
|
req,
|
||||||
backupTimeStart,
|
error: new Error("Failed to create kernel request"),
|
||||||
backupTimeEnd,
|
})
|
||||||
"success"
|
|
||||||
)
|
|
||||||
|
|
||||||
response.next(processedRes)
|
|
||||||
response.complete()
|
response.complete()
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response.next({
|
return service.execute(kernelRequest)
|
||||||
type: "network_fail",
|
|
||||||
req,
|
|
||||||
error: res.left,
|
|
||||||
})
|
|
||||||
response.complete()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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()
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,17 @@
|
||||||
import { getService } from "~/modules/dioc"
|
|
||||||
import { PersistenceService } from "~/services/persistence"
|
|
||||||
|
|
||||||
import * as E from "fp-ts/Either"
|
import * as E from "fp-ts/Either"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { InterceptorService } from "~/services/interceptor.service"
|
|
||||||
|
|
||||||
import { AxiosRequestConfig } from "axios"
|
import { getService } from "~/modules/dioc"
|
||||||
|
import { PersistenceService } from "~/services/persistence"
|
||||||
|
import { KernelInterceptorService } from "~/services/kernel-interceptor.service"
|
||||||
|
import { content } from "@hoppscotch/kernel"
|
||||||
|
|
||||||
|
const kernelInterceptor = getService(KernelInterceptorService)
|
||||||
|
const persistenceService = getService(PersistenceService)
|
||||||
|
|
||||||
const redirectUri = `${window.location.origin}/oauth`
|
const redirectUri = `${window.location.origin}/oauth`
|
||||||
|
|
||||||
const interceptorService = getService(InterceptorService)
|
export type TokenRequestParams = {
|
||||||
const persistenceService = getService(PersistenceService)
|
|
||||||
|
|
||||||
// GENERAL HELPER FUNCTIONS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a query string into an object
|
|
||||||
*
|
|
||||||
* @param {String} searchQuery - The search query params
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const parseQueryString = (searchQuery: string): Record<string, string> => {
|
|
||||||
if (searchQuery === "") {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
const segments = searchQuery.split("&").map((s) => s.split("="))
|
|
||||||
const queryString = segments.reduce(
|
|
||||||
(obj, el) => ({ ...obj, [el[0]]: el[1] }),
|
|
||||||
{}
|
|
||||||
)
|
|
||||||
return queryString
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get OAuth configuration from OpenID Discovery endpoint
|
|
||||||
*
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const getTokenConfiguration = async (endpoint: string) => {
|
|
||||||
const options = {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-type": "application/json",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await runRequestThroughInterceptor({
|
|
||||||
url: endpoint,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (E.isLeft(res)) {
|
|
||||||
return E.left("OIDC_DISCOVERY_FAILED")
|
|
||||||
}
|
|
||||||
|
|
||||||
return E.right(JSON.parse(res.right))
|
|
||||||
} catch (e) {
|
|
||||||
return E.left("OIDC_DISCOVERY_FAILED")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// PKCE HELPER FUNCTIONS
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a secure random string using the browser crypto functions
|
|
||||||
*
|
|
||||||
* @returns {Object}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const generateRandomString = () => {
|
|
||||||
const array = new Uint32Array(28)
|
|
||||||
window.crypto.getRandomValues(array)
|
|
||||||
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate the SHA256 hash of the input text
|
|
||||||
*
|
|
||||||
* @returns {Promise<ArrayBuffer>}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const sha256 = (plain: string) => {
|
|
||||||
const encoder = new TextEncoder()
|
|
||||||
const data = encoder.encode(plain)
|
|
||||||
return window.crypto.subtle.digest("SHA-256", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encodes the input string into Base64 format
|
|
||||||
*
|
|
||||||
* @param {String} str - The string to be converted
|
|
||||||
* @returns {Promise<ArrayBuffer>}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const base64urlencode = (
|
|
||||||
str: ArrayBuffer // Converts the ArrayBuffer to string using Uint8 array to convert to what btoa accepts.
|
|
||||||
) => {
|
|
||||||
const hashArray = Array.from(new Uint8Array(str))
|
|
||||||
|
|
||||||
// btoa accepts chars only within ascii 0-255 and base64 encodes them.
|
|
||||||
// Then convert the base64 encoded to base64url encoded
|
|
||||||
// (replace + with -, replace / with _, trim trailing =)
|
|
||||||
return btoa(String.fromCharCode.apply(null, hashArray))
|
|
||||||
.replace(/\+/g, "-")
|
|
||||||
.replace(/\//g, "_")
|
|
||||||
.replace(/=+$/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the base64-urlencoded sha256 hash for the PKCE challenge
|
|
||||||
*
|
|
||||||
* @param {String} v - The randomly generated string
|
|
||||||
* @returns {String}
|
|
||||||
*/
|
|
||||||
|
|
||||||
const pkceChallengeFromVerifier = async (v: string) => {
|
|
||||||
const hashed = await sha256(v)
|
|
||||||
return base64urlencode(hashed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OAUTH REQUEST
|
|
||||||
|
|
||||||
type TokenRequestParams = {
|
|
||||||
oidcDiscoveryUrl: string
|
oidcDiscoveryUrl: string
|
||||||
grantType: string
|
grantType: string
|
||||||
authUrl: string
|
authUrl: string
|
||||||
|
|
@ -133,14 +21,48 @@ type TokenRequestParams = {
|
||||||
scope: string
|
scope: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getTokenConfiguration(endpoint: string) {
|
||||||
* Initiates PKCE Auth Code flow when requested
|
const { response } = kernelInterceptor.execute({
|
||||||
*
|
id: Date.now(),
|
||||||
* @param {Object} - The necessary params
|
url: endpoint,
|
||||||
* @returns {Void}
|
method: "GET",
|
||||||
*/
|
version: "HTTP/1.1",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": ["application/json"],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const tokenRequest = async ({
|
const result = await response
|
||||||
|
if (E.isLeft(result)) return E.left("OIDC_DISCOVERY_FAILED")
|
||||||
|
|
||||||
|
const jsonContent = result.right.content
|
||||||
|
if (jsonContent.kind !== "json") return E.left("OIDC_DISCOVERY_FAILED")
|
||||||
|
|
||||||
|
return E.right(jsonContent.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRandomString = () => {
|
||||||
|
const array = new Uint32Array(28)
|
||||||
|
window.crypto.getRandomValues(array)
|
||||||
|
return Array.from(array, (dec) => `0${dec.toString(16)}`.slice(-2)).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64urlencode = (str: ArrayBuffer) => {
|
||||||
|
const hashArray = Array.from(new Uint8Array(str))
|
||||||
|
return btoa(String.fromCharCode.apply(null, hashArray))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkceChallengeFromVerifier = async (v: string) => {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const data = encoder.encode(v)
|
||||||
|
const hashed = await window.crypto.subtle.digest("SHA-256", data)
|
||||||
|
return base64urlencode(hashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokenRequest = async ({
|
||||||
oidcDiscoveryUrl,
|
oidcDiscoveryUrl,
|
||||||
grantType,
|
grantType,
|
||||||
authUrl,
|
authUrl,
|
||||||
|
|
@ -157,123 +79,91 @@ const tokenRequest = async ({
|
||||||
token_endpoint: z.string(),
|
token_endpoint: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (E.isLeft(res)) {
|
if (E.isLeft(res)) return E.left("OIDC_DISCOVERY_FAILED")
|
||||||
return E.left("OIDC_DISCOVERY_FAILED" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
|
const parsedOIDCConfiguration = OIDCConfigurationSchema.safeParse(res.right)
|
||||||
|
if (!parsedOIDCConfiguration.success) return E.left("OIDC_DISCOVERY_FAILED")
|
||||||
if (!parsedOIDCConfiguration.success) {
|
|
||||||
return E.left("OIDC_DISCOVERY_FAILED" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
authUrl = parsedOIDCConfiguration.data.authorization_endpoint
|
authUrl = parsedOIDCConfiguration.data.authorization_endpoint
|
||||||
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
|
accessTokenUrl = parsedOIDCConfiguration.data.token_endpoint
|
||||||
}
|
}
|
||||||
// Store oauth information
|
|
||||||
persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
|
|
||||||
persistenceService.setLocalConfig("client_id", clientId)
|
|
||||||
persistenceService.setLocalConfig("client_secret", clientSecret)
|
|
||||||
|
|
||||||
// Create and store a random state value
|
await persistenceService.setLocalConfig("tokenEndpoint", accessTokenUrl)
|
||||||
|
await persistenceService.setLocalConfig("client_id", clientId)
|
||||||
|
await persistenceService.setLocalConfig("client_secret", clientSecret)
|
||||||
|
|
||||||
const state = generateRandomString()
|
const state = generateRandomString()
|
||||||
persistenceService.setLocalConfig("pkce_state", state)
|
await persistenceService.setLocalConfig("pkce_state", state)
|
||||||
|
|
||||||
// Create and store a new PKCE codeVerifier (the plaintext random secret)
|
|
||||||
const codeVerifier = generateRandomString()
|
const codeVerifier = generateRandomString()
|
||||||
persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier)
|
await persistenceService.setLocalConfig("pkce_codeVerifier", codeVerifier)
|
||||||
|
|
||||||
// Hash and base64-urlencode the secret to use as the challenge
|
|
||||||
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
|
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier)
|
||||||
|
|
||||||
// Build the authorization URL
|
const url = new URL(authUrl)
|
||||||
const buildUrl = () =>
|
url.searchParams.set("response_type", grantType)
|
||||||
`${authUrl + `?response_type=${grantType}`}&client_id=${encodeURIComponent(
|
url.searchParams.set("client_id", clientId)
|
||||||
clientId
|
url.searchParams.set("state", state)
|
||||||
)}&state=${encodeURIComponent(state)}&scope=${encodeURIComponent(
|
url.searchParams.set("scope", scope)
|
||||||
scope
|
url.searchParams.set("redirect_uri", redirectUri)
|
||||||
)}&redirect_uri=${encodeURIComponent(
|
url.searchParams.set("code_challenge", codeChallenge)
|
||||||
redirectUri
|
url.searchParams.set("code_challenge_method", "S256")
|
||||||
)}&code_challenge=${encodeURIComponent(
|
|
||||||
codeChallenge
|
|
||||||
)}&code_challenge_method=S256`
|
|
||||||
|
|
||||||
// Redirect to the authorization server
|
window.location.assign(url.toString())
|
||||||
window.location.assign(buildUrl())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAUTH REDIRECT HANDLING
|
export const handleOAuthRedirect = async () => {
|
||||||
|
const queryParams = Object.fromEntries(
|
||||||
|
new URLSearchParams(window.location.search)
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
if (queryParams.error) return E.left("AUTH_SERVER_RETURNED_ERROR")
|
||||||
* Handle the redirect back from the authorization server and
|
if (!queryParams.code) return E.left("NO_AUTH_CODE")
|
||||||
* get an access token from the token endpoint
|
if (
|
||||||
*
|
(await persistenceService.getLocalConfig("pkce_state")) !==
|
||||||
* @returns {Promise<any | void>}
|
queryParams.state
|
||||||
*/
|
) {
|
||||||
|
return E.left("INVALID_STATE")
|
||||||
const handleOAuthRedirect = async () => {
|
|
||||||
const queryParams = parseQueryString(window.location.search.substring(1))
|
|
||||||
|
|
||||||
// Check if the server returned an error string
|
|
||||||
if (queryParams.error) {
|
|
||||||
return E.left("AUTH_SERVER_RETURNED_ERROR" as const)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!queryParams.code) {
|
const tokenEndpoint = await persistenceService.getLocalConfig("tokenEndpoint")
|
||||||
return E.left("NO_AUTH_CODE" as const)
|
const clientID = await persistenceService.getLocalConfig("client_id")
|
||||||
}
|
const clientSecret = await persistenceService.getLocalConfig("client_secret")
|
||||||
|
const codeVerifier =
|
||||||
|
await persistenceService.getLocalConfig("pkce_codeVerifier")
|
||||||
|
|
||||||
// If the server returned an authorization code, attempt to exchange it for an access token
|
if (!tokenEndpoint) return E.left("NO_TOKEN_ENDPOINT")
|
||||||
// Verify state matches what we set at the beginning
|
if (!clientID) return E.left("NO_CLIENT_ID")
|
||||||
if (persistenceService.getLocalConfig("pkce_state") !== queryParams.state) {
|
if (!clientSecret) return E.left("NO_CLIENT_SECRET")
|
||||||
return E.left("INVALID_STATE" as const)
|
if (!codeVerifier) return E.left("NO_CODE_VERIFIER")
|
||||||
}
|
|
||||||
|
|
||||||
const tokenEndpoint = persistenceService.getLocalConfig("tokenEndpoint")
|
const requestParams = {
|
||||||
const clientID = persistenceService.getLocalConfig("client_id")
|
|
||||||
const clientSecret = persistenceService.getLocalConfig("client_secret")
|
|
||||||
const codeVerifier = persistenceService.getLocalConfig("pkce_codeVerifier")
|
|
||||||
|
|
||||||
if (!tokenEndpoint) {
|
|
||||||
return E.left("NO_TOKEN_ENDPOINT" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientID) {
|
|
||||||
return E.left("NO_CLIENT_ID" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!clientSecret) {
|
|
||||||
return E.left("NO_CLIENT_SECRET" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!codeVerifier) {
|
|
||||||
return E.left("NO_CODE_VERIFIER" as const)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
code: queryParams.code,
|
code: queryParams.code,
|
||||||
client_id: clientID,
|
client_id: clientID,
|
||||||
client_secret: clientSecret,
|
client_secret: clientSecret,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
})
|
}
|
||||||
|
|
||||||
// Exchange the authorization code for an access token
|
const { response } = kernelInterceptor.execute({
|
||||||
const tokenResponse = await runRequestThroughInterceptor({
|
id: Date.now(),
|
||||||
url: tokenEndpoint,
|
url: tokenEndpoint,
|
||||||
data: data.toString(),
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
version: "HTTP/1.1",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": ["application/x-www-form-urlencoded"],
|
||||||
},
|
},
|
||||||
|
content: content.urlencoded(requestParams),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Clean these up since we don't need them anymore
|
|
||||||
clearPKCEState()
|
clearPKCEState()
|
||||||
|
|
||||||
if (E.isLeft(tokenResponse)) {
|
const result = await response
|
||||||
return E.left("AUTH_TOKEN_REQUEST_FAILED" as const)
|
if (E.isLeft(result)) return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||||
|
|
||||||
|
if (result.right.content.kind !== "json") {
|
||||||
|
return E.left("AUTH_TOKEN_REQUEST_FAILED")
|
||||||
}
|
}
|
||||||
|
|
||||||
const withAccessTokenSchema = z.object({
|
const withAccessTokenSchema = z.object({
|
||||||
|
|
@ -281,36 +171,18 @@ const handleOAuthRedirect = async () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
const parsedTokenResponse = withAccessTokenSchema.safeParse(
|
||||||
JSON.parse(tokenResponse.right)
|
result.right.content.content
|
||||||
)
|
)
|
||||||
|
|
||||||
return parsedTokenResponse.success
|
return parsedTokenResponse.success
|
||||||
? E.right(parsedTokenResponse.data)
|
? E.right(parsedTokenResponse.data)
|
||||||
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE" as const)
|
: E.left("AUTH_TOKEN_REQUEST_INVALID_RESPONSE")
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearPKCEState = () => {
|
const clearPKCEState = async () => {
|
||||||
persistenceService.removeLocalConfig("pkce_state")
|
await persistenceService.removeLocalConfig("pkce_state")
|
||||||
persistenceService.removeLocalConfig("pkce_codeVerifier")
|
await persistenceService.removeLocalConfig("pkce_codeVerifier")
|
||||||
persistenceService.removeLocalConfig("tokenEndpoint")
|
await persistenceService.removeLocalConfig("tokenEndpoint")
|
||||||
persistenceService.removeLocalConfig("client_id")
|
await persistenceService.removeLocalConfig("client_id")
|
||||||
persistenceService.removeLocalConfig("client_secret")
|
await persistenceService.removeLocalConfig("client_secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRequestThroughInterceptor(config: AxiosRequestConfig) {
|
|
||||||
const res = await interceptorService.runRequest(config).response
|
|
||||||
|
|
||||||
if (E.isLeft(res)) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
|
||||||
}
|
|
||||||
|
|
||||||
// convert ArrayBuffer to string
|
|
||||||
if (!(res.right.data instanceof ArrayBuffer)) {
|
|
||||||
return E.left("REQUEST_FAILED")
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new TextDecoder().decode(res.right.data).replace(/\0+$/, "")
|
|
||||||
return E.right(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { tokenRequest, handleOAuthRedirect }
|
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,12 @@ export class TeamSearchService extends Service {
|
||||||
this.searchResultsRequests = {}
|
this.searchResultsRequests = {}
|
||||||
this.expandedCollections.value = []
|
this.expandedCollections.value = []
|
||||||
|
|
||||||
const axiosPlatformConfig = platform.auth.axiosPlatformConfig?.() ?? {}
|
const getAxiosPlatformConfig = async () => {
|
||||||
|
await platform.auth.waitProbableLoginToConfirm()
|
||||||
|
return platform.auth.axiosPlatformConfig?.() ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosPlatformConfig = await getAxiosPlatformConfig()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const searchResponse = await axios.get(
|
const searchResponse = await axios.get(
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,69 @@
|
||||||
import { HoppRESTRequest } from "@hoppscotch/data"
|
import { HoppRESTRequest } from "@hoppscotch/data"
|
||||||
import { Component } from "vue"
|
import { Component } from "vue"
|
||||||
|
import { KernelInterceptorError } from "~/services/kernel-interceptor.service"
|
||||||
|
|
||||||
export type HoppRESTResponseHeader = { key: string; value: string }
|
export type HoppRESTResponseHeader = { key: string; value: string }
|
||||||
|
|
||||||
|
export type HoppRESTSuccessResponse = {
|
||||||
|
type: "success"
|
||||||
|
headers: HoppRESTResponseHeader[]
|
||||||
|
body: ArrayBuffer
|
||||||
|
statusCode: number
|
||||||
|
statusText: string
|
||||||
|
meta: {
|
||||||
|
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 =
|
export type HoppRESTResponse =
|
||||||
| { type: "loading"; req: HoppRESTRequest }
|
| HoppRESTLoadingResponse
|
||||||
| {
|
| HoppRESTSuccessResponse
|
||||||
type: "fail"
|
| HoppRESTFailureResponse
|
||||||
headers: HoppRESTResponseHeader[]
|
| HoppRESTFailureNetwork
|
||||||
body: ArrayBuffer
|
| HoppRESTFailureScript
|
||||||
statusCode: number
|
| HoppRESTFailureExtension
|
||||||
statusText: string
|
| HoppRESTFailureInterceptor
|
||||||
meta: {
|
|
||||||
responseSize: number // in bytes
|
|
||||||
responseDuration: number // in millis
|
|
||||||
}
|
|
||||||
|
|
||||||
req: HoppRESTRequest
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "network_fail"
|
|
||||||
error: unknown
|
|
||||||
|
|
||||||
req: HoppRESTRequest
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "script_fail"
|
|
||||||
error: Error
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "success"
|
|
||||||
headers: HoppRESTResponseHeader[]
|
|
||||||
body: ArrayBuffer
|
|
||||||
statusCode: number
|
|
||||||
statusText: string
|
|
||||||
meta: {
|
|
||||||
responseSize: number // in bytes
|
|
||||||
responseDuration: number // in millis
|
|
||||||
}
|
|
||||||
|
|
||||||
req: HoppRESTRequest
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "extension_error"
|
|
||||||
error: string
|
|
||||||
component: Component
|
|
||||||
req: HoppRESTRequest
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue