mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 18:04:06 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ca0447241 | ||
|
|
145c2315e5 | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a | ||
|
|
2d2a390bfd | ||
|
|
5cce23566a | ||
|
|
e99f6d2bc7 | ||
|
|
bea58b16b4 | ||
|
|
93fba4d9b4 | ||
|
|
b9071eafe0 | ||
|
|
778c74c635 | ||
|
|
0f434361a7 | ||
|
|
d27d11af7c | ||
|
|
1a19a06a23 | ||
|
|
07a9a6c6c0 | ||
|
|
e54240d579 | ||
|
|
8bca013ab4 | ||
|
|
10e962a0e6 | ||
|
|
78954e10c8 | ||
|
|
9eb7a001da |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
target
|
||||
**/target
|
||||
.claude
|
||||
vendored
|
||||
**/vendored
|
||||
*.log
|
||||
.git
|
||||
@@ -103,6 +103,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
# clang-18 handles it (it is what ubuntu-24.04 uses). Install it from
|
||||
# apt.llvm.org since 22.04's repos stop at 15. Only the wasm build uses
|
||||
# this compiler, so the shipped binary keeps 22.04's glibc floor.
|
||||
wget -qO /tmp/llvm.sh https://apt.llvm.org/llvm.sh
|
||||
chmod +x /tmp/llvm.sh
|
||||
sudo /tmp/llvm.sh 18
|
||||
echo "CC_wasm32_unknown_unknown=/usr/bin/clang-18" >> "$GITHUB_ENV"
|
||||
echo "AR_wasm32_unknown_unknown=/usr/bin/llvm-ar-18" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install Protoc for plugin-runtime
|
||||
uses: arduino/setup-protoc@v3
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Release web image
|
||||
|
||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||
# joined into one multi-arch tag at the end.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||
- name: Tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short
|
||||
|
||||
- name: Create and push the manifest
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
Generated
+95
-32
@@ -249,7 +249,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"url",
|
||||
@@ -265,7 +265,7 @@ dependencies = [
|
||||
"enumflags2",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"raw-window-handle",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
@@ -585,9 +585,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.16.1"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
@@ -595,14 +595,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.38.0"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -618,6 +619,8 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
@@ -626,10 +629,15 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -650,6 +658,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3275,6 +3284,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -4463,7 +4478,7 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
|
||||
dependencies = [
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5023,15 +5038,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -5055,18 +5069,18 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.5.0+3.5.0"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -5880,7 +5894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5890,7 +5904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6455,9 +6469,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -6466,9 +6480,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.1"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.3",
|
||||
@@ -7348,7 +7362,7 @@ dependencies = [
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"rkyv",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9509,7 +9523,7 @@ dependencies = [
|
||||
"indexmap 1.9.3",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -9531,6 +9545,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9539,12 +9554,22 @@ version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -9569,6 +9594,7 @@ version = "0.1.41"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -9726,7 +9752,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log 0.4.29",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"sha1",
|
||||
@@ -9996,7 +10022,7 @@ checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
|
||||
dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"js-sys",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
@@ -11196,6 +11222,7 @@ name = "yaak"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"serde_json",
|
||||
@@ -11240,7 +11267,7 @@ dependencies = [
|
||||
"pretty_graphql",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"rlimit",
|
||||
"serde",
|
||||
@@ -11276,6 +11303,7 @@ dependencies = [
|
||||
"yaak-grpc",
|
||||
"yaak-http",
|
||||
"yaak-license",
|
||||
"yaak-lifecycle",
|
||||
"yaak-mac-window",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
@@ -11325,7 +11353,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"oxc_resolver",
|
||||
"predicates",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.7",
|
||||
"reqwest 0.12.20",
|
||||
"rolldown",
|
||||
"schemars 0.8.22",
|
||||
@@ -11341,6 +11369,7 @@ dependencies = [
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
@@ -11351,7 +11380,6 @@ dependencies = [
|
||||
name = "yaak-commands"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
@@ -11490,7 +11518,6 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"regex 1.11.1",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11526,6 +11553,14 @@ dependencies = [
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-mac-window"
|
||||
version = "0.1.0"
|
||||
@@ -11534,7 +11569,7 @@ dependencies = [
|
||||
"csscolorparser",
|
||||
"log 0.4.29",
|
||||
"objc",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
]
|
||||
@@ -11559,8 +11594,10 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs",
|
||||
"urlencoding",
|
||||
"yaak-core",
|
||||
"yaak-database",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11576,7 +11613,7 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.7.0",
|
||||
"path-slash",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11732,7 +11769,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
@@ -11745,6 +11782,32 @@ dependencies = [
|
||||
"sqlite-wasm-vfs",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"clap",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"log 0.4.29",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"ts-rs",
|
||||
"url",
|
||||
"uuid",
|
||||
"yaak-http",
|
||||
"yaak-models",
|
||||
]
|
||||
|
||||
@@ -11754,7 +11817,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"log 0.4.29",
|
||||
"md5 0.8.0",
|
||||
"rand 0.9.1",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
@@ -21,11 +22,13 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -77,6 +80,7 @@ yaak-crypto = { path = "crates/yaak-crypto" }
|
||||
yaak-git = { path = "crates/yaak-git" }
|
||||
yaak-grpc = { path = "crates/yaak-grpc" }
|
||||
yaak-http = { path = "crates/yaak-http" }
|
||||
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
|
||||
yaak-models = { path = "crates/yaak-models" }
|
||||
yaak-plugins = { path = "crates/yaak-plugins" }
|
||||
yaak-sse = { path = "crates/yaak-sse" }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||
# image serving both from one origin.
|
||||
#
|
||||
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
#
|
||||
# See crates-server/yaak-web/README.md for the knobs.
|
||||
|
||||
FROM node:22-slim AS web
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||
RUN git init -q && git add -A \
|
||||
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||
ARG VITE_YAAK_WEB_URL=""
|
||||
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||
ENV YAAK_TARGET=web
|
||||
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||
# backend, which this image has no reason to carry.
|
||||
ENV SKIP_WASM_BUILD=1
|
||||
RUN npm ci
|
||||
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||
|
||||
FROM rust:1-bookworm AS server
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-web
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||
CMD ["yaak-web", "--serve", "/srv"]
|
||||
@@ -1,33 +1,42 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||
// doesn't take you away from the request you were working on.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
// Imported here so Settings stays out of the startup bundle, the way the
|
||||
// route that renders it on desktop already keeps it
|
||||
const { default: Settings } = await import("../components/Settings/Settings");
|
||||
showDialog({
|
||||
id: "settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
@@ -42,25 +43,19 @@ const tabs = [
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await platform.window.close();
|
||||
}
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
defaultValue={mainTab}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { parse as jsonLintParse } from "@prantlf/jsonlint";
|
||||
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
|
||||
|
||||
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
|
||||
|
||||
// jsonc-parser reports error codes, so these are the words the editor shows for them
|
||||
const MESSAGES: Record<string, string> = {
|
||||
InvalidSymbol: "Invalid symbol",
|
||||
InvalidNumberFormat: "Invalid number format",
|
||||
PropertyNameExpected: "Property name expected",
|
||||
ValueExpected: "Value expected",
|
||||
ColonExpected: "Colon expected",
|
||||
CommaExpected: "Comma expected",
|
||||
CloseBraceExpected: "Closing brace expected",
|
||||
CloseBracketExpected: "Closing bracket expected",
|
||||
EndOfFileExpected: "End of file expected",
|
||||
InvalidCommentToken: "Comments are not allowed",
|
||||
UnexpectedEndOfComment: "Unexpected end of comment",
|
||||
UnexpectedEndOfString: "Unexpected end of string",
|
||||
UnexpectedEndOfNumber: "Unexpected end of number",
|
||||
InvalidUnicode: "Invalid unicode sequence",
|
||||
InvalidEscapeCharacter: "Invalid escape character",
|
||||
InvalidCharacter: "Invalid character",
|
||||
};
|
||||
|
||||
interface JsonLintOptions {
|
||||
allowComments?: boolean;
|
||||
allowTrailingCommas?: boolean;
|
||||
@@ -11,34 +31,28 @@ interface JsonLintOptions {
|
||||
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
try {
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
jsonLintParse(escapedDoc, {
|
||||
mode: (options?.allowComments ?? true) ? "cjson" : "json",
|
||||
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
} catch (err: any) {
|
||||
if (!("location" in err)) {
|
||||
return [];
|
||||
}
|
||||
const doc = view.state.doc.toString();
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
|
||||
// const line = location?.start?.line;
|
||||
// const column = location?.start?.column;
|
||||
if (err.location.start.offset) {
|
||||
return [
|
||||
{
|
||||
from: err.location.start.offset,
|
||||
to: err.location.start.offset,
|
||||
severity: "error",
|
||||
message: err.message,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
const errors: ParseError[] = [];
|
||||
parse(escapedDoc, errors, {
|
||||
allowTrailingComma: options?.allowTrailingCommas ?? false,
|
||||
disallowComments: !(options?.allowComments ?? true),
|
||||
});
|
||||
|
||||
// Later errors are mostly consequences of the first one, so only that one is shown
|
||||
const error = errors[0];
|
||||
if (error == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
from: error.offset,
|
||||
to: error.offset + error.length,
|
||||
severity: "error",
|
||||
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { CsvViewerInner } from "./CsvViewer";
|
||||
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||
}));
|
||||
|
||||
describe("CsvViewer", () => {
|
||||
test("renders columns that extend beyond the first row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CsvViewerInner
|
||||
text={[
|
||||
"startDate,2026-02-03T00:00-03:00",
|
||||
"endDate,2026-02-03T23:59:59-03:00",
|
||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("ID de usuario");
|
||||
expect(markup).toContain("42041");
|
||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
const header = parsed.data[0] ?? [];
|
||||
const rows = parsed.data.slice(1);
|
||||
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
{columnIndexes.map((columnIndex) => (
|
||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
{rows.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||
{row.map((cell, columnIndex) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||
for (const action of hotkeyActions) {
|
||||
merged[action] = defaultHotkeys[action];
|
||||
}
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
if (action in merged && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||
|
||||
/**
|
||||
* The actions this host actually has. An action left out of here has no keys in
|
||||
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||
*/
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
)
|
||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||
.sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
@@ -333,7 +345,9 @@ export function formatHotkeyString(trigger: string): string[] {
|
||||
} else if (p === "Alt") {
|
||||
labelParts.push("⌥");
|
||||
} else if (p === "Enter") {
|
||||
labelParts.push("↩");
|
||||
// U+21A9 has an emoji presentation, which Chromium's font fallback picks
|
||||
// (a blue glyph among monochrome ones). U+FE0E forces the text form.
|
||||
labelParts.push("↩︎");
|
||||
} else if (p === "Tab") {
|
||||
labelParts.push("⇥");
|
||||
} else if (p === "Backspace") {
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
"@prantlf/jsonlint": "^16.0.0",
|
||||
"@replit/codemirror-emacs": "^6.1.0",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@replit/codemirror-vscode-keymap": "^6.0.2",
|
||||
@@ -54,6 +53,7 @@
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
@@ -93,14 +93,12 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"decompress": "^4.2.1",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ const WorkspacesWorkspaceIdRequestsRequestIdRoute =
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/workspaces': typeof WorkspacesIndexRoute
|
||||
'/workspaces/': typeof WorkspacesIndexRoute
|
||||
'/workspaces/$workspaceId/settings': typeof WorkspacesWorkspaceIdSettingsRoute
|
||||
'/workspaces/$workspaceId': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/': typeof WorkspacesWorkspaceIdIndexRoute
|
||||
'/workspaces/$workspaceId/requests/$requestId': typeof WorkspacesWorkspaceIdRequestsRequestIdRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -70,9 +70,9 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/workspaces'
|
||||
| '/workspaces/'
|
||||
| '/workspaces/$workspaceId/settings'
|
||||
| '/workspaces/$workspaceId'
|
||||
| '/workspaces/$workspaceId/'
|
||||
| '/workspaces/$workspaceId/requests/$requestId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -110,14 +110,14 @@ declare module '@tanstack/react-router' {
|
||||
'/workspaces/': {
|
||||
id: '/workspaces/'
|
||||
path: '/workspaces'
|
||||
fullPath: '/workspaces'
|
||||
fullPath: '/workspaces/'
|
||||
preLoaderRoute: typeof WorkspacesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/workspaces/$workspaceId/': {
|
||||
id: '/workspaces/$workspaceId/'
|
||||
path: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId'
|
||||
fullPath: '/workspaces/$workspaceId/'
|
||||
preLoaderRoute: typeof WorkspacesWorkspaceIdIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Settings />;
|
||||
const { tab } = Route.useSearch();
|
||||
return <Settings tab={tab} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import topLevelAwait from "vite-plugin-top-level-await";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -43,10 +42,11 @@ export default defineConfig(async () => {
|
||||
: {},
|
||||
},
|
||||
// The browser host runs the model layer in a worker; that bundle needs the
|
||||
// same wasm and top-level-await handling as the main one.
|
||||
// same wasm handling as the main one. Top-level await needs no transform
|
||||
// because the build targets esnext.
|
||||
worker: {
|
||||
format: "es" as const,
|
||||
plugins: () => [wasm(), topLevelAwait()],
|
||||
plugins: () => [wasm()],
|
||||
},
|
||||
plugins: [
|
||||
wasm(),
|
||||
@@ -58,7 +58,6 @@ export default defineConfig(async () => {
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
topLevelAwait(),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
||||
"vite-plus": "^0.2.1"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plus": "^0.2.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ yaak-api = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-templates = { workspace = true }
|
||||
|
||||
@@ -181,7 +181,11 @@ async fn dev(args: PluginPathArg) -> CommandResult {
|
||||
ui::info(&format!("Rebuilding plugin {display_path}"));
|
||||
}
|
||||
WatcherEvent::Event(BundleEvent::BundleEnd(_)) => {
|
||||
match generate_plugin_metadata(&watch_root) {
|
||||
// Assets are staged on every rebuild, so a changed asset or
|
||||
// declaration is picked up without restarting.
|
||||
let result = copy_build_assets(&watch_root)
|
||||
.and_then(|()| generate_plugin_metadata(&watch_root));
|
||||
match result {
|
||||
Ok(()) => ui::success(&format!(
|
||||
"Generated plugin metadata at {}",
|
||||
watch_root.join("build/metadata.json").display()
|
||||
@@ -408,6 +412,7 @@ struct PublishResponse {
|
||||
|
||||
async fn build_plugin_bundle(plugin_dir: &Path) -> CommandResult<Vec<String>> {
|
||||
prepare_build_output_dir(plugin_dir)?;
|
||||
copy_build_assets(plugin_dir)?;
|
||||
let mut bundler = Bundler::new(bundler_options(plugin_dir, false))
|
||||
.map_err(|err| format!("Failed to initialize Rolldown: {err}"))?;
|
||||
let output = bundler.write().await.map_err(|err| format!("Plugin build failed:\n{err}"))?;
|
||||
@@ -498,6 +503,63 @@ fn prepare_build_output_dir(plugin_dir: &Path) -> CommandResult {
|
||||
.map_err(|e| format!("Failed to create build directory {}: {e}", build_dir.display()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifest {
|
||||
#[serde(default)]
|
||||
yaak: PluginManifestConfig,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct PluginManifestConfig {
|
||||
/// Files to place beside the bundle, as paths relative to the plugin
|
||||
/// directory. Publishing ships everything in `build/`, so these travel with
|
||||
/// the plugin.
|
||||
#[serde(default, rename = "buildAssets")]
|
||||
build_assets: Vec<String>,
|
||||
}
|
||||
|
||||
/// Copy the plugin's declared assets into `build/`.
|
||||
///
|
||||
/// This runs after the directory is cleared and before the bundle is written,
|
||||
/// because a bundle may read an asset from its own directory at import time and
|
||||
/// metadata generation imports the bundle.
|
||||
fn copy_build_assets(plugin_dir: &Path) -> CommandResult {
|
||||
let manifest_path = plugin_dir.join("package.json");
|
||||
let manifest: PluginManifest = serde_json::from_str(
|
||||
&fs::read_to_string(&manifest_path)
|
||||
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to parse {}: {e}", manifest_path.display()))?;
|
||||
|
||||
let build_dir = plugin_dir.join("build");
|
||||
let mut names = HashSet::new();
|
||||
for asset in manifest.yaak.build_assets {
|
||||
let src = plugin_dir.join(&asset);
|
||||
let name = src
|
||||
.file_name()
|
||||
.ok_or_else(|| format!("yaak.buildAssets entry is not a file path: {asset}"))?;
|
||||
// A copy that later gets overwritten would pass the build and fail on
|
||||
// load, so anything the build itself writes, or a second asset with
|
||||
// the same name, is rejected up front. Names are compared without
|
||||
// case, because a plugin is installed on case-insensitive filesystems
|
||||
// wherever it was built.
|
||||
let key = name.to_string_lossy().to_lowercase();
|
||||
if key == "index.js" || key == "metadata.json" {
|
||||
return Err(format!("Build asset {asset} would be overwritten by the build output"));
|
||||
}
|
||||
if !names.insert(key) {
|
||||
return Err(format!("Two build assets share the name {}", name.display()));
|
||||
}
|
||||
if !src.is_file() {
|
||||
return Err(format!("Build asset does not exist: {}", src.display()));
|
||||
}
|
||||
fs::copy(&src, build_dir.join(name))
|
||||
.map_err(|e| format!("Failed to copy build asset {}: {e}", src.display()))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bundler_options(plugin_dir: &Path, watch: bool) -> BundlerOptions {
|
||||
BundlerOptions {
|
||||
input: Some(vec![InputItem { import: "./src/index.ts".to_string(), ..Default::default() }]),
|
||||
@@ -750,7 +812,10 @@ describe("Example Plugin", () => {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{create_publish_archive, generate_plugin_metadata};
|
||||
use super::{
|
||||
copy_build_assets, create_publish_archive, generate_plugin_metadata,
|
||||
prepare_build_output_dir,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -795,6 +860,100 @@ mod tests {
|
||||
assert!(!names.contains("ignored/secret.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_build_output_dir_clears_stale_output() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
let build = root.join("build");
|
||||
fs::create_dir_all(&build).expect("create build");
|
||||
fs::write(build.join("index.js"), "stale").expect("write index.js");
|
||||
fs::write(build.join("left-behind.js"), "stale").expect("write extra");
|
||||
|
||||
prepare_build_output_dir(root).expect("prepare build dir");
|
||||
|
||||
// Publishing ships everything under build/, so nothing may survive.
|
||||
assert!(build.is_dir());
|
||||
assert_eq!(fs::read_dir(&build).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_places_declared_files_beside_the_bundle() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("vendor")).expect("create vendor");
|
||||
fs::write(root.join("vendor/core_bg.wasm"), "asset").expect("write asset");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["vendor/core_bg.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("build/core_bg.wasm")).expect("read copied asset"),
|
||||
"asset"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_is_a_noop_without_declarations() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"name":"demo"}"#).expect("write package.json");
|
||||
|
||||
copy_build_assets(root).expect("copy assets");
|
||||
|
||||
assert_eq!(fs::read_dir(root.join("build")).expect("read build").count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_names_the_build_writes() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("index.js"), "asset").expect("write asset");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["index.js"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("reserved name should fail");
|
||||
assert!(err.contains("overwritten by the build output"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_rejects_duplicate_names() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::create_dir_all(root.join("a")).expect("create a");
|
||||
fs::create_dir_all(root.join("b")).expect("create b");
|
||||
// Differ only by case: one file on macOS and Windows.
|
||||
fs::write(root.join("a/core.wasm"), "one").expect("write a");
|
||||
fs::write(root.join("b/Core.wasm"), "two").expect("write b");
|
||||
fs::write(
|
||||
root.join("package.json"),
|
||||
r#"{"yaak":{"buildAssets":["a/core.wasm","b/Core.wasm"]}}"#,
|
||||
)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("duplicate name should fail");
|
||||
assert!(err.contains("share the name"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_build_assets_fails_on_a_missing_asset() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let root = dir.path();
|
||||
fs::create_dir_all(root.join("build")).expect("create build");
|
||||
fs::write(root.join("package.json"), r#"{"yaak":{"buildAssets":["nope.wasm"]}}"#)
|
||||
.expect("write package.json");
|
||||
|
||||
let err = copy_build_assets(root).expect_err("missing asset should fail");
|
||||
assert!(err.contains("Build asset does not exist"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_plugin_metadata_detects_api_types() {
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
|
||||
@@ -435,15 +435,12 @@ fn create(
|
||||
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
|
||||
let name = name.unwrap_or_default();
|
||||
let url = url.unwrap_or_default();
|
||||
let method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let request = HttpRequest {
|
||||
workspace_id,
|
||||
name,
|
||||
method: method.to_uppercase(),
|
||||
url,
|
||||
..Default::default()
|
||||
};
|
||||
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
|
||||
// Only override the method when one was given; `HttpRequest::default()` is the
|
||||
// single place the fallback ("GET") is defined.
|
||||
if let Some(method) = method {
|
||||
request.method = method.to_uppercase();
|
||||
}
|
||||
|
||||
let created = ctx
|
||||
.db()
|
||||
|
||||
@@ -49,6 +49,14 @@ impl CliContext {
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Guest: the desktop may have this DB open, so only what's safe beside a live session
|
||||
let _ = yaak_lifecycle::on_launch(
|
||||
&yaak_lifecycle::Host::guest(),
|
||||
&query_manager.connect(),
|
||||
&blob_manager,
|
||||
);
|
||||
|
||||
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
|
||||
|
||||
Self {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::context::CliExecutionContext;
|
||||
use arboard::Clipboard;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use console::Term;
|
||||
use inquire::{Confirm, Editor, Password, PasswordDisplayMode, Select, Text};
|
||||
use serde_json::Value;
|
||||
@@ -11,7 +13,8 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
@@ -131,6 +134,7 @@ async fn build_plugin_reply(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
&host_context.query_manager,
|
||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
||||
&event.payload,
|
||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||
) {
|
||||
@@ -223,7 +227,15 @@ async fn build_plugin_reply(
|
||||
.await
|
||||
{
|
||||
Ok(result) => Some(InternalEventPayload::SendHttpRequestResponse(
|
||||
SendHttpRequestResponse { http_response: result.response },
|
||||
SendHttpRequestResponse {
|
||||
http_response: result.response,
|
||||
// Nothing saved this body, so the reply is the only
|
||||
// place the plugin can get it.
|
||||
body: result
|
||||
.response_body
|
||||
.returned_bytes()
|
||||
.map(|b| BASE64_STANDARD.encode(b)),
|
||||
},
|
||||
)),
|
||||
Err(err) => Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to send HTTP request in CLI: {err}"),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
|
||||
|
||||
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
|
||||
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
|
||||
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
|
||||
# opens no database, runs no plugins, and renders nothing. yaak-models comes
|
||||
# along only because yaak-http's types are its types; nothing here calls into
|
||||
# its query layer.
|
||||
|
||||
[[bin]]
|
||||
name = "yaak-web"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
axum = "0.7"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
env_logger = "0.11"
|
||||
futures-util = "0.3"
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
|
||||
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
|
||||
ts-rs = { workspace = true }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,219 @@
|
||||
# yaak-web
|
||||
|
||||
The network half of Yaak in a browser — and, with `--serve`, the half that
|
||||
hands the browser the app in the first place.
|
||||
|
||||
A tab can't see an HTTP response the way a desktop app can: CORS hides most
|
||||
headers (2 of 8 in a typical response), redirects are followed silently, and
|
||||
there is no timeline. So the tab renders the request and posts it here, and this
|
||||
process puts it on the network with the desktop's own engine (`yaak-http`) and
|
||||
streams back everything that happened — every header, every redirect hop, DNS
|
||||
timing, the body — for the tab to store.
|
||||
|
||||
It is a **stateless executor**. It keeps nothing: no database, no files, no
|
||||
sessions, no cookies between calls. Every byte it sees comes from the tab in the
|
||||
request, and every byte it returns is stored by the tab. Restart it any time.
|
||||
|
||||
## Self-hosting it
|
||||
|
||||
One container, no configuration, nothing behind it:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
```
|
||||
|
||||
Open <http://localhost:8080>. The image carries the built web client and this
|
||||
binary, which serves it — so the app and its sends are on one origin, and the
|
||||
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
|
||||
configure. The image is `linux/amd64` and `linux/arm64`, built from
|
||||
`Dockerfile.web` at the repo root.
|
||||
|
||||
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
|
||||
the container. The container is stateless: nothing is written to disk, so
|
||||
upgrading is `docker pull` and nothing else.
|
||||
|
||||
Two settings are worth knowing about:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 \
|
||||
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
|
||||
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
|
||||
ghcr.io/mountain-loop/yaak-web
|
||||
```
|
||||
|
||||
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
|
||||
private and link-local addresses. Off by default, and it should stay off on
|
||||
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
|
||||
Turn it on for an instance on your own network, where calling the API on the
|
||||
next machine is the whole point. Note that "private" is relative to the
|
||||
*container*: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
host means `host.docker.internal` (or `--network host`).
|
||||
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
|
||||
which suits a public instance and not a team of your own; `0` disables it.
|
||||
|
||||
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
|
||||
limit sees real client addresses instead of its own — and only then, since
|
||||
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
|
||||
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
|
||||
binary sets is honoured by nginx-shaped ones.
|
||||
|
||||
## Running it from source
|
||||
|
||||
```shell
|
||||
cargo run -p yaak-web -- --serve dist/apps/yaak-client
|
||||
```
|
||||
|
||||
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
|
||||
Without `--serve` it is the send executor alone, which is what the frontend
|
||||
dev server wants:
|
||||
|
||||
```shell
|
||||
cargo run -p yaak-web
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
|
||||
different origin and serves no `/v1`); a production build sends to its own
|
||||
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
|
||||
arguments; `--help` lists them all.
|
||||
|
||||
| Flag | Default | What |
|
||||
| --- | --- | --- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||
|
||||
## Serving the app
|
||||
|
||||
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
|
||||
first, everything else comes from `DIR`, and a path with no file behind it gets
|
||||
`index.html` so the app's own routes survive a refresh. Responses are compressed
|
||||
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
|
||||
those names — and everything else is `no-cache`, so a new deploy arrives on the
|
||||
next reload.
|
||||
|
||||
Serving files changes nothing about sending: the same rendered request, the same
|
||||
destination policy, the same stateless executor. It exists so that a
|
||||
self-hosted Yaak is one thing to run rather than two.
|
||||
|
||||
## Split deployments
|
||||
|
||||
The app and the sender can still be separate services — one CDN-hosted bundle and
|
||||
one server elsewhere, or one server shared by several fronts. Then the bundle has
|
||||
to be told where to send, at build time:
|
||||
|
||||
```shell
|
||||
docker build -f Dockerfile.web \
|
||||
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
|
||||
```
|
||||
|
||||
and the server needs the CORS origins its callers use, since the requests are no
|
||||
longer same-origin:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 \
|
||||
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
|
||||
ghcr.io/mountain-loop/yaak-web \
|
||||
yaak-web
|
||||
```
|
||||
|
||||
The trailing `yaak-web` is a command override: the same image run without
|
||||
`--serve`, so it executes sends and serves no app.
|
||||
|
||||
## What it refuses, and why
|
||||
|
||||
A hosted sender is, by construction, a machine that makes HTTP requests on
|
||||
behalf of strangers. Left alone that is an open relay into whatever network it
|
||||
sits on. So by default it refuses to connect to:
|
||||
|
||||
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
|
||||
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
|
||||
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
|
||||
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
|
||||
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
|
||||
- anything not `http://` or `https://`.
|
||||
|
||||
The check runs **on the resolved addresses, after DNS**, for every hop of a
|
||||
redirect chain, so a public hostname that points at an internal address is
|
||||
caught, and so is a `Location:` header that points at one. It also refuses body
|
||||
types that would read files on its own disk (`binary`, multipart file
|
||||
fields), since no browser tab could legitimately mean those.
|
||||
|
||||
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
|
||||
anything else strangers can reach) this must stay on: the machine's private
|
||||
network is the host's, not the user's, so a `localhost` or LAN API is not the
|
||||
user's to reach through it — the desktop app is what reaches those. On an
|
||||
instance you run for yourself, that reasoning is inverted, and
|
||||
`--allow-private-networks` inverts the policy with it. It allows every range
|
||||
above, including `169.254.169.254`, so use it only where the network on the
|
||||
other side is one the users are entitled to.
|
||||
|
||||
There is no authentication either way: an instance is anonymous, protected by
|
||||
the per-client rate limit and the destination policy. Anything more (a shared
|
||||
token, per-user quotas) is a later slice and would sit in front of `send_http`
|
||||
in `main.rs`. Put TLS in front of a public instance.
|
||||
|
||||
## The wire
|
||||
|
||||
`POST /v1/http/send` with a JSON body:
|
||||
|
||||
```json
|
||||
{
|
||||
"request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] },
|
||||
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
|
||||
"cookies": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
|
||||
template already rendered by the tab; the server builds the URL, headers and
|
||||
body from it exactly the way the desktop does after rendering. `cookies` is the
|
||||
jar's contents (or `null` for no jar).
|
||||
|
||||
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
|
||||
happened:
|
||||
|
||||
| `type` | When | Carries |
|
||||
| --- | --- | --- |
|
||||
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
|
||||
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||
|
||||
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
||||
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
|
||||
`{"error": "…"}`, not streams.
|
||||
|
||||
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
|
||||
construction, cancellable by closing the connection, readable with `curl`, and
|
||||
needs no upgrade handling on either side. A WebSocket only earns its keep when
|
||||
traffic is bidirectional, which a single send is not.
|
||||
|
||||
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
|
||||
into `bindings/` (run `cargo test -p yaak-web` after changing a frame)
|
||||
and published to the tab as `@yaakapp-internal/web`, so a change to the
|
||||
wire on one side is a type error on the other.
|
||||
|
||||
`GET /v1/health` reports the version and the effective limits.
|
||||
|
||||
## What comes later
|
||||
|
||||
Not built, by design, but the router is shaped for it: a WebSocket relay
|
||||
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
|
||||
bidirectional endpoints on the same binary, behind the same destination policy
|
||||
and limits. They differ from this endpoint in holding per-connection
|
||||
in-memory state while a connection is open (never persisted), which brings
|
||||
connection limits and a larger abuse surface — the reason they are separate
|
||||
work.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
|
||||
|
||||
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
|
||||
|
||||
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
|
||||
|
||||
export type CookieSameSite = "Strict" | "Lax" | "None";
|
||||
|
||||
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
|
||||
/**
|
||||
* URL parameters used for both path placeholders (`:id`) and query string entries.
|
||||
*/
|
||||
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
/**
|
||||
* Serializable representation of HTTP response events for DB storage.
|
||||
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
|
||||
* The `From` impl is in yaak-http to avoid circular dependencies.
|
||||
*/
|
||||
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
|
||||
|
||||
export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
* Other entries are appended as query parameters
|
||||
*/
|
||||
name: string, value: string, id?: string, };
|
||||
|
||||
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
|
||||
|
||||
export type InheritedIntSetting = { enabled?: boolean, value: number, };
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
|
||||
|
||||
/**
|
||||
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
|
||||
* camelCase like every model the tab stores.
|
||||
*/
|
||||
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
|
||||
/**
|
||||
* The URL that answered, after redirects.
|
||||
*/
|
||||
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
|
||||
/**
|
||||
* The headers that were actually sent on the final hop, cookies and all.
|
||||
*/
|
||||
requestHeaders: Array<HttpResponseHeader>,
|
||||
/**
|
||||
* `Content-Length` as declared by the server, if it declared one.
|
||||
*/
|
||||
contentLength: number | null,
|
||||
/**
|
||||
* Milliseconds from the start of the send to the response head.
|
||||
*/
|
||||
elapsedHeaders: number,
|
||||
/**
|
||||
* Milliseconds spent in DNS on the last lookup, or zero.
|
||||
*/
|
||||
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
|
||||
/**
|
||||
* Milliseconds from the start of the send to the end of the body.
|
||||
*/
|
||||
elapsed: number,
|
||||
/**
|
||||
* Bytes of body relayed, after decompression.
|
||||
*/
|
||||
contentLength: number,
|
||||
/**
|
||||
* Bytes on the wire as declared by the server, or the relayed size when unknown.
|
||||
*/
|
||||
contentLengthCompressed: number,
|
||||
/**
|
||||
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
|
||||
*/
|
||||
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
|
||||
|
||||
/**
|
||||
* The body of `POST /v1/http/send`.
|
||||
*/
|
||||
export type SendRequest = {
|
||||
/**
|
||||
* The request to send, in the desktop's own model shape but with every template already
|
||||
* rendered by the tab. The server builds the URL, headers and body from it exactly the way
|
||||
* the desktop does after rendering.
|
||||
*/
|
||||
request: HttpRequest,
|
||||
/**
|
||||
* The resolved settings, values only. Where they came from is the tab's to record in
|
||||
* its timeline; the server only needs to obey them.
|
||||
*/
|
||||
settings: HttpSendSettings,
|
||||
/**
|
||||
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
|
||||
*/
|
||||
cookies: Array<Cookie> | null, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// The server's wire contract, generated by ts-rs from src/wire.rs
|
||||
// (`cargo test -p yaak-web`). The tab imports these so a change to a
|
||||
// frame on the Rust side is a type error in packages/platform/src/web.
|
||||
export type { Frame, SendRequest } from "./bindings/gen_web";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The server behind Yaak running in a browser.
|
||||
///
|
||||
/// The tab renders the request and owns the data; this binary puts the bytes on the network
|
||||
/// and streams back what came back, and with `--serve` hands the browser the app as well.
|
||||
/// Nothing is written to disk or a database.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-web", version, about, long_about = None)]
|
||||
pub struct Config {
|
||||
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
|
||||
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
|
||||
pub bind: SocketAddr,
|
||||
|
||||
/// Also serve a built web client from this directory, on the same origin as the API.
|
||||
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
|
||||
/// Without this the binary is only the send executor.
|
||||
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
|
||||
pub serve: Option<PathBuf>,
|
||||
|
||||
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
|
||||
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
|
||||
/// only for an instance whose users are meant to reach that network — a self-hosted one
|
||||
/// on a LAN, where the point is to call the API on the next machine.
|
||||
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
|
||||
pub allow_private_networks: bool,
|
||||
|
||||
/// Browser origins allowed to call this server (CORS), comma-separated. `*` allows any.
|
||||
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_WEB_ALLOWED_ORIGINS",
|
||||
default_value = "*",
|
||||
value_delimiter = ','
|
||||
)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
|
||||
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
|
||||
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
|
||||
pub max_request_bytes: usize,
|
||||
|
||||
/// Largest upstream response body the server will relay before cutting the send off.
|
||||
#[arg(long, env = "YAAK_WEB_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
|
||||
pub max_response_bytes: usize,
|
||||
|
||||
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
|
||||
/// gets this instead.
|
||||
#[arg(long, env = "YAAK_WEB_MAX_TIMEOUT_SECS", default_value_t = 60)]
|
||||
pub max_timeout_secs: u64,
|
||||
|
||||
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
|
||||
/// cap are the whole of what protects an instance: there is no authentication.
|
||||
#[arg(long, env = "YAAK_WEB_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
|
||||
pub rate_limit_per_minute: u32,
|
||||
|
||||
/// Sends in flight at once across all clients.
|
||||
#[arg(long, env = "YAAK_WEB_MAX_CONCURRENT", default_value_t = 256)]
|
||||
pub max_concurrent: usize,
|
||||
|
||||
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
|
||||
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
|
||||
/// past the rate limit.
|
||||
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
|
||||
pub trust_forwarded_for: bool,
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Where a send may go.
|
||||
//!
|
||||
//! A hosted sender is, by construction, a machine that makes HTTP requests on
|
||||
//! behalf of strangers. Left alone that is an open relay into whatever network
|
||||
//! it sits on: cloud metadata endpoints, internal admin panels, the database
|
||||
//! next door. So every destination is checked twice — once on the URL before a
|
||||
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
|
||||
//! addresses a hostname actually resolves to, right before the connection is
|
||||
//! made. The second check is the one that matters for a hostname pointing at
|
||||
//! an internal address, and it runs on every redirect hop because the engine
|
||||
//! resolves every hop.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::warn;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use url::Url;
|
||||
use yaak_http::dns::AddressFilter;
|
||||
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
|
||||
use yaak_http::types::SendableHttpRequest;
|
||||
|
||||
/// The destination policy, shared by every send: public addresses only, unless the operator
|
||||
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
|
||||
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
|
||||
/// network *is* the user's, which is what `--allow-private-networks` means.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DestinationPolicy {
|
||||
allow_private: bool,
|
||||
}
|
||||
|
||||
impl DestinationPolicy {
|
||||
pub fn new(allow_private: bool) -> Self {
|
||||
Self { allow_private }
|
||||
}
|
||||
|
||||
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
|
||||
/// here still has its resolved addresses checked by [`Self::address_filter`].
|
||||
pub fn check_url(&self, raw: &str) -> Result<(), String> {
|
||||
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
|
||||
match url.scheme() {
|
||||
"http" | "https" => {}
|
||||
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
|
||||
}
|
||||
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
|
||||
let host = host.trim_matches(|c| c == '[' || c == ']');
|
||||
|
||||
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
|
||||
// where their addresses become known.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
self.check_ip(ip)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The veto the engine's resolver applies to every address a hostname resolves to.
|
||||
pub fn address_filter(&self) -> AddressFilter {
|
||||
let policy = self.clone();
|
||||
Arc::new(move |ip| policy.check_ip(ip))
|
||||
}
|
||||
|
||||
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
|
||||
if self.allow_private {
|
||||
return Ok(());
|
||||
}
|
||||
match non_public_reason(ip) {
|
||||
Some(reason) => Err(format!(
|
||||
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an address is not a public internet address, or `None` if it is one.
|
||||
///
|
||||
/// Every range here is one a hosted relay must never be talked into reaching: the machine
|
||||
/// itself, the network it sits on, and the link-local range where cloud metadata services
|
||||
/// (169.254.169.254) live. IPv4 addresses carried inside fixed-layout IPv6 forms — IPv4-mapped,
|
||||
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
|
||||
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
|
||||
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; a network-specific
|
||||
/// NAT64 prefix is not knowable here.
|
||||
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => non_public_v4(v4),
|
||||
IpAddr::V6(v6) => {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
return non_public_v4(v4);
|
||||
}
|
||||
if let Some(v4) = embedded_v4(&v6) {
|
||||
return non_public_v4(v4);
|
||||
}
|
||||
if v6.is_loopback() {
|
||||
Some("loopback")
|
||||
} else if v6.is_unspecified() {
|
||||
Some("unspecified")
|
||||
} else if v6.is_unique_local() {
|
||||
Some("unique local (fc00::/7)")
|
||||
} else if v6.is_unicast_link_local() {
|
||||
Some("link-local (fe80::/10)")
|
||||
} else if v6.is_multicast() {
|
||||
Some("multicast")
|
||||
} else if v6.segments()[..3] == [0x64, 0xff9b, 1] {
|
||||
Some("NAT64 local-use (64:ff9b:1::/48)")
|
||||
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
|
||||
Some("discard-only (100::/64)")
|
||||
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
|
||||
Some("site-local (fec0::/10)")
|
||||
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
|
||||
Some("documentation (2001:db8::/32)")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
|
||||
let o = v4.octets();
|
||||
if v4.is_loopback() {
|
||||
Some("loopback (127.0.0.0/8)")
|
||||
} else if v4.is_private() {
|
||||
Some("private (10/8, 172.16/12, 192.168/16)")
|
||||
} else if v4.is_link_local() {
|
||||
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
|
||||
} else if v4.is_unspecified() || o[0] == 0 {
|
||||
Some("this network (0.0.0.0/8)")
|
||||
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
|
||||
Some("carrier-grade NAT (100.64.0.0/10)")
|
||||
} else if v4.is_broadcast() {
|
||||
Some("broadcast")
|
||||
} else if v4.is_multicast() {
|
||||
Some("multicast (224.0.0.0/4)")
|
||||
} else if o[0] >= 240 {
|
||||
Some("reserved (240.0.0.0/4)")
|
||||
} else if v4.is_documentation() {
|
||||
Some("documentation")
|
||||
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
|
||||
Some("IETF protocol assignments (192.0.0.0/24)")
|
||||
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
|
||||
Some("benchmarking (198.18.0.0/15)")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPv4 address an IPv6 address stands for, when it is one of the fixed-layout translation
|
||||
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
|
||||
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
|
||||
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
|
||||
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
|
||||
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
|
||||
let s = v6.segments();
|
||||
let o = v6.octets();
|
||||
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
|
||||
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
|
||||
}
|
||||
if s[0] == 0x2002 {
|
||||
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
|
||||
///
|
||||
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
|
||||
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
|
||||
/// including a redirect to a literal internal IP, which the resolver would never see.
|
||||
pub struct GuardedSender<S> {
|
||||
inner: S,
|
||||
policy: DestinationPolicy,
|
||||
}
|
||||
|
||||
impl<S: HttpSender> GuardedSender<S> {
|
||||
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
|
||||
Self { inner, policy }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: HttpSender> HttpSender for GuardedSender<S> {
|
||||
async fn send(
|
||||
&self,
|
||||
request: SendableHttpRequest,
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> yaak_http::error::Result<HttpResponse> {
|
||||
if let Err(reason) = self.policy.check_url(&request.url) {
|
||||
warn!("Refused {} {}: {reason}", request.method, request.url);
|
||||
return Err(yaak_http::error::Error::RequestError(reason));
|
||||
}
|
||||
self.inner.send(request, event_tx).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_the_ranges_a_relay_must_never_reach() {
|
||||
for addr in [
|
||||
"127.0.0.1",
|
||||
"127.9.9.9",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"169.254.0.1",
|
||||
"0.0.0.0",
|
||||
"100.64.0.1",
|
||||
"255.255.255.255",
|
||||
"224.0.0.1",
|
||||
"240.0.0.1",
|
||||
"::1",
|
||||
"::",
|
||||
"fc00::1",
|
||||
"fd12::1",
|
||||
"fe80::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:169.254.169.254",
|
||||
"64:ff9b::7f00:1",
|
||||
"ff02::1",
|
||||
] {
|
||||
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allows_public_addresses() {
|
||||
for addr in [
|
||||
"1.1.1.1",
|
||||
"8.8.8.8",
|
||||
"93.184.216.34",
|
||||
"172.32.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
] {
|
||||
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_private_addresses_in_urls_are_refused() {
|
||||
let policy = DestinationPolicy::new(false);
|
||||
assert!(policy.check_url("http://127.0.0.1/").is_err());
|
||||
assert!(policy.check_url("http://[::1]/").is_err());
|
||||
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
|
||||
let policy = DestinationPolicy::new(true);
|
||||
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
||||
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
|
||||
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
|
||||
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_http_schemes() {
|
||||
let policy = DestinationPolicy::new(false);
|
||||
assert!(policy.check_url("ftp://example.com/").is_err());
|
||||
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||
assert!(policy.check_url("https://example.com/").is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Per-client rate limiting, kept deliberately small.
|
||||
//!
|
||||
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
|
||||
//! map that is swept of idle entries as it goes. Good enough to keep one
|
||||
//! caller from monopolising a hosted instance; not a substitute for whatever
|
||||
//! sits in front of it in production.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct RateLimiter {
|
||||
per_minute: u32,
|
||||
buckets: Mutex<HashMap<IpAddr, Bucket>>,
|
||||
}
|
||||
|
||||
struct Bucket {
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// `per_minute == 0` disables limiting.
|
||||
pub fn new(per_minute: u32) -> Self {
|
||||
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// Take one token for `client`, or say how long until one is available.
|
||||
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
|
||||
if self.per_minute == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let capacity = self.per_minute as f64;
|
||||
let per_second = capacity / 60.0;
|
||||
let now = Instant::now();
|
||||
|
||||
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
// Sweep buckets that have been idle long enough to be full again; there is nothing
|
||||
// to remember about them.
|
||||
if buckets.len() > 1024 {
|
||||
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
|
||||
}
|
||||
|
||||
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
|
||||
let elapsed = now.duration_since(bucket.last).as_secs_f64();
|
||||
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
|
||||
bucket.last = now;
|
||||
|
||||
if bucket.tokens >= 1.0 {
|
||||
bucket.tokens -= 1.0;
|
||||
Ok(())
|
||||
} else {
|
||||
let wait = (1.0 - bucket.tokens) / per_second;
|
||||
Err(Duration::from_secs_f64(wait.max(0.001)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_full_bucket_then_a_wait() {
|
||||
let limiter = RateLimiter::new(3);
|
||||
let ip: IpAddr = "203.0.113.5".parse().unwrap();
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
assert!(limiter.check(ip).is_ok());
|
||||
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
|
||||
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_are_independent_and_zero_disables() {
|
||||
let limiter = RateLimiter::new(1);
|
||||
let a: IpAddr = "203.0.113.5".parse().unwrap();
|
||||
let b: IpAddr = "203.0.113.6".parse().unwrap();
|
||||
assert!(limiter.check(a).is_ok());
|
||||
assert!(limiter.check(a).is_err());
|
||||
assert!(limiter.check(b).is_ok());
|
||||
|
||||
let unlimited = RateLimiter::new(0);
|
||||
for _ in 0..1000 {
|
||||
assert!(unlimited.check(a).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! yaak-web: the network half of Yaak in a browser.
|
||||
//!
|
||||
//! A tab can't see a response the way a desktop app can — CORS hides most
|
||||
//! headers, redirects are followed silently, there is no timeline. So the tab
|
||||
//! renders the request and hands it here; this process puts it on the network
|
||||
//! with the desktop's own engine and streams back everything that happened,
|
||||
//! for the tab to store. It keeps nothing: no database, no files, no session.
|
||||
//!
|
||||
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
|
||||
//! See README.md for running and deploying it, and `guard.rs` for what it
|
||||
//! refuses to talk to.
|
||||
|
||||
mod config;
|
||||
mod guard;
|
||||
mod limits;
|
||||
mod send;
|
||||
mod wire;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use clap::Parser;
|
||||
use config::Config;
|
||||
use guard::DestinationPolicy;
|
||||
use limits::RateLimiter;
|
||||
use log::{info, warn};
|
||||
use send::{Refusal, SendLimits};
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use wire::SendRequest;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Arc<Config>,
|
||||
limits: Arc<SendLimits>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
in_flight: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let config = Config::parse();
|
||||
|
||||
let policy = DestinationPolicy::new(config.allow_private_networks);
|
||||
if config.allow_private_networks {
|
||||
warn!(
|
||||
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
|
||||
on an instance strangers cannot reach"
|
||||
);
|
||||
}
|
||||
let state = AppState {
|
||||
limits: Arc::new(SendLimits {
|
||||
policy,
|
||||
max_response_bytes: config.max_response_bytes,
|
||||
max_timeout: Duration::from_secs(config.max_timeout_secs),
|
||||
}),
|
||||
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE])
|
||||
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
||||
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
||||
.route("/v1/http/send", post(send_http))
|
||||
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
let app = match &state.config.serve {
|
||||
Some(dir) => {
|
||||
info!("Serving the web client from {}", dir.display());
|
||||
api.merge(web_router(dir))
|
||||
}
|
||||
None => api,
|
||||
};
|
||||
|
||||
let bind = state.config.bind;
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to bind {bind}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
info!(
|
||||
"yaak-web listening on http://{bind} (rate limit: {}/min)",
|
||||
state.config.rate_limit_per_minute,
|
||||
);
|
||||
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
info!("Shutting down");
|
||||
})
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
/// The built web client, served on the same origin as the API.
|
||||
///
|
||||
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
|
||||
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
|
||||
/// only a file server — a send behaves exactly as it does without this flag.
|
||||
///
|
||||
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
|
||||
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
|
||||
/// refresh).
|
||||
fn web_router(dir: &Path) -> Router {
|
||||
let index = ServeFile::new(dir.join("index.html"));
|
||||
Router::new()
|
||||
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
|
||||
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
|
||||
.fallback_service(ServeDir::new(dir).fallback(index))
|
||||
.layer(middleware::from_fn(cache_control))
|
||||
.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
|
||||
/// Everything else — `index.html` above all, including the copy served for an unknown path —
|
||||
/// must be revalidated, or a browser keeps serving the deploy before last.
|
||||
async fn cache_control(req: Request, next: Next) -> Response {
|
||||
let hashed_name = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
if !res.status().is_success() {
|
||||
return res;
|
||||
}
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value = if hashed_name && !is_html {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
fn allowed_origins(origins: &[String]) -> AllowOrigin {
|
||||
if origins.iter().any(|o| o.trim() == "*") {
|
||||
return AllowOrigin::any();
|
||||
}
|
||||
let parsed: Vec<HeaderValue> =
|
||||
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
|
||||
AllowOrigin::list(parsed)
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"maxResponseBytes": state.config.max_response_bytes,
|
||||
"maxTimeoutSecs": state.config.max_timeout_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||
let message = message.into();
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
|
||||
/// hop when the operator has said the header can be trusted.
|
||||
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
if config.trust_forwarded_for
|
||||
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first) = forwarded.split(',').next()
|
||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
peer.ip()
|
||||
}
|
||||
|
||||
async fn send_http(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SendRequest>,
|
||||
) -> Response {
|
||||
let ip = client_ip(&state.config, &headers, peer);
|
||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||
warn!("Rate limited {ip}");
|
||||
let mut res = error_response(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
|
||||
);
|
||||
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
|
||||
return res;
|
||||
}
|
||||
|
||||
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
|
||||
warn!("At capacity; refusing {ip}");
|
||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
|
||||
};
|
||||
|
||||
let prepared = match send::prepare(state.limits.clone(), body).await {
|
||||
Ok(p) => p,
|
||||
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Destination(m)) => {
|
||||
warn!("Refused send from {ip}: {m}");
|
||||
return error_response(StatusCode::FORBIDDEN, m);
|
||||
}
|
||||
};
|
||||
|
||||
let description = prepared.describe();
|
||||
info!("{ip} -> {description}");
|
||||
let started = Instant::now();
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
|
||||
tokio::spawn(async move {
|
||||
prepared.run(tx).await;
|
||||
send::log_outcome(&description, started, "finished");
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
let stream = tokio_stream_from(rx);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/x-ndjson")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
// Some reverse proxies buffer streamed responses unless told not to
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn tokio_stream_from<T: Send + 'static>(
|
||||
mut rx: tokio::sync::mpsc::Receiver<T>,
|
||||
) -> impl futures_util::Stream<Item = T> + Send + 'static {
|
||||
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! The one thing this binary does: execute a rendered request and stream back what happened.
|
||||
//!
|
||||
//! This is the "execute" half of the desktop's `send_http_request` — the part after rendering
|
||||
//! and before storage — driven through the same `HttpTransaction` the desktop drives, with the
|
||||
//! same redirect loop, cookie jar, decompression and timeline events. Everything the desktop
|
||||
//! would write to its database is written to the reply stream instead, and the tab stores it.
|
||||
|
||||
use crate::guard::{DestinationPolicy, GuardedSender};
|
||||
use crate::wire::{Frame, SendRequest};
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use log::{info, warn};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use yaak_http::client::{HttpConnectionOptions, HttpConnectionProxySetting};
|
||||
use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::sender::{HttpResponseEvent, ReqwestSender};
|
||||
use yaak_http::transaction::HttpTransaction;
|
||||
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
|
||||
use yaak_models::models::HttpResponseHeader;
|
||||
|
||||
/// How many frames may sit unread by the client before body reading pauses. Backpressure, so a
|
||||
/// slow tab slows the upstream read rather than filling memory.
|
||||
pub const FRAME_CHANNEL_CAPACITY: usize = 64;
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 256;
|
||||
const BODY_READ_CHUNK: usize = 64 * 1024;
|
||||
|
||||
/// What a send needs from the process, beyond the request itself.
|
||||
pub struct SendLimits {
|
||||
pub policy: DestinationPolicy,
|
||||
pub max_response_bytes: usize,
|
||||
pub max_timeout: Duration,
|
||||
}
|
||||
|
||||
/// Why a send was refused before anything was put on the network. Distinct from a failure
|
||||
/// mid-stream: these become a plain HTTP error, not a stream with an error frame.
|
||||
#[derive(Debug)]
|
||||
pub enum Refusal {
|
||||
/// The request asks for something a browser-originated send cannot mean.
|
||||
Unsupported(String),
|
||||
/// The destination is not one this server will talk to.
|
||||
Destination(String),
|
||||
/// The request could not be turned into something sendable.
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
pub type FrameSender = mpsc::Sender<Result<Bytes, Infallible>>;
|
||||
|
||||
/// Check and prepare a send, then hand back the task that runs it. Refusals happen here, before
|
||||
/// the caller has committed to a streaming response.
|
||||
pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<PreparedSend, Refusal> {
|
||||
let request = send.request;
|
||||
|
||||
// The engine reads files for these body types. There are no files here that a browser tab
|
||||
// could legitimately mean, and letting a request name a path on this machine would be a
|
||||
// local file read for anyone who can reach the server.
|
||||
if request.body_type.as_deref() == Some("binary") {
|
||||
return Err(Refusal::Unsupported(
|
||||
"Binary file bodies can't be sent from the browser: the server has no access to your files"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
if request.body_type.as_deref() == Some("multipart/form-data") {
|
||||
let names_a_file =
|
||||
request.body.get("form").and_then(|f| f.as_array()).is_some_and(|entries| {
|
||||
entries.iter().any(|e| {
|
||||
e.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)
|
||||
&& e.get("file").and_then(|v| v.as_str()).is_some_and(|f| !f.is_empty())
|
||||
})
|
||||
});
|
||||
if names_a_file {
|
||||
return Err(Refusal::Unsupported(
|
||||
"Multipart file fields can't be sent from the browser: the server has no access to your files"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// The tab's requested timeout, capped. Zero means "none", which here means the cap.
|
||||
let requested = if send.settings.timeout_ms > 0 {
|
||||
Some(Duration::from_millis(send.settings.timeout_ms as u64))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let timeout = requested.map_or(limits.max_timeout, |t| t.min(limits.max_timeout));
|
||||
let timeout_capped = requested.is_none_or(|t| t > limits.max_timeout);
|
||||
|
||||
let sendable = SendableHttpRequest::from_http_request(
|
||||
&request,
|
||||
SendableHttpRequestOptions {
|
||||
timeout: Some(timeout),
|
||||
follow_redirects: send.settings.follow_redirects,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| Refusal::Invalid(e.to_string()))?;
|
||||
|
||||
// The first hop, checked up front so a bad destination is a clean refusal rather than a
|
||||
// stream that opens and immediately errors. Every later hop is checked by GuardedSender.
|
||||
limits.policy.check_url(&sendable.url).map_err(Refusal::Destination)?;
|
||||
|
||||
Ok(PreparedSend {
|
||||
limits,
|
||||
sendable,
|
||||
settings: send.settings,
|
||||
cookies: send.cookies,
|
||||
timeout,
|
||||
timeout_capped,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct PreparedSend {
|
||||
limits: Arc<SendLimits>,
|
||||
sendable: SendableHttpRequest,
|
||||
settings: yaak_models::models::HttpSendSettings,
|
||||
cookies: Option<Vec<yaak_models::models::Cookie>>,
|
||||
timeout: Duration,
|
||||
timeout_capped: bool,
|
||||
}
|
||||
|
||||
impl PreparedSend {
|
||||
pub fn describe(&self) -> String {
|
||||
format!("{} {}", self.sendable.method, self.sendable.url)
|
||||
}
|
||||
|
||||
/// Run the send, writing frames to `frames` until the terminal frame. Returns when the
|
||||
/// stream is complete or the client has gone away.
|
||||
pub async fn run(mut self, frames: FrameSender) {
|
||||
let cookie_store = self.cookies.take().map(CookieStore::from_cookies);
|
||||
let store_for_result = cookie_store.clone();
|
||||
let outcome = self.execute(frames.clone(), cookie_store).await;
|
||||
|
||||
let cookies = store_for_result.as_ref().map(|s| s.get_all_cookies());
|
||||
let terminal = match outcome {
|
||||
Ok(done) => Frame::Done {
|
||||
elapsed: done.elapsed,
|
||||
content_length: done.content_length,
|
||||
content_length_compressed: done.content_length_compressed,
|
||||
cookies,
|
||||
},
|
||||
Err(message) => Frame::Error { message, cookies },
|
||||
};
|
||||
let _ = write_frame(&frames, &terminal).await;
|
||||
}
|
||||
|
||||
async fn execute(
|
||||
self,
|
||||
frames: FrameSender,
|
||||
cookie_store: Option<CookieStore>,
|
||||
) -> Result<DoneStats, String> {
|
||||
let limits = self.limits;
|
||||
|
||||
let (client, resolver) = HttpConnectionOptions {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
validate_certificates: self.settings.validate_certificates,
|
||||
// The proxy connects directly. Going through a system proxy would move DNS, and
|
||||
// therefore the address check, somewhere this process can't see.
|
||||
proxy: HttpConnectionProxySetting::Disabled,
|
||||
client_certificate: None,
|
||||
dns_overrides: Vec::new(),
|
||||
address_filter: Some(limits.policy.address_filter()),
|
||||
}
|
||||
.build_client()
|
||||
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
|
||||
|
||||
// Timeline events go into the same frame stream as everything else, as they happen.
|
||||
// The desktop persists them from a task like this one; here the task serialises them.
|
||||
let (event_tx, mut event_rx) = mpsc::channel::<HttpResponseEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
resolver.set_event_sender(Some(event_tx.clone())).await;
|
||||
let dns_elapsed = Arc::new(AtomicU64::new(0));
|
||||
let event_frames = frames.clone();
|
||||
let event_dns = dns_elapsed.clone();
|
||||
let event_task = tokio::spawn(async move {
|
||||
while let Some(event) = event_rx.recv().await {
|
||||
if let HttpResponseEvent::DnsResolved { duration, .. } = &event {
|
||||
event_dns.store(*duration, Ordering::Relaxed);
|
||||
}
|
||||
let frame = Frame::Event { event: event.into() };
|
||||
if write_frame(&event_frames, &frame).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Cancellation: the client hanging up, or the overall deadline. The deadline exists
|
||||
// because a per-hop timeout times each hop separately; ten slow redirects must not add
|
||||
// up to ten timeouts.
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let deadline = self.timeout * 2 + Duration::from_secs(5);
|
||||
let deadline_cancel = cancel_tx.clone();
|
||||
let deadline_task = tokio::spawn(async move {
|
||||
tokio::time::sleep(deadline).await;
|
||||
let _ = deadline_cancel.send(true);
|
||||
});
|
||||
let hangup_frames = frames.clone();
|
||||
let hangup_task = tokio::spawn(async move {
|
||||
hangup_frames.closed().await;
|
||||
let _ = cancel_tx.send(true);
|
||||
});
|
||||
|
||||
if self.timeout_capped {
|
||||
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
|
||||
"Timeout set to {:?} (this server's ceiling)",
|
||||
self.timeout
|
||||
)));
|
||||
}
|
||||
|
||||
let sender = GuardedSender::new(ReqwestSender::with_client(client), limits.policy.clone());
|
||||
let transaction = match cookie_store {
|
||||
Some(store) => HttpTransaction::with_cookie_behavior(
|
||||
sender,
|
||||
store,
|
||||
self.settings.send_cookies,
|
||||
self.settings.store_cookies,
|
||||
),
|
||||
None => HttpTransaction::new(sender),
|
||||
};
|
||||
|
||||
let started_at = Instant::now();
|
||||
let result = transaction
|
||||
.execute_with_cancellation(self.sendable, cancel_rx.clone(), event_tx.clone())
|
||||
.await;
|
||||
resolver.set_event_sender(None).await;
|
||||
|
||||
let mut response = match result {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
drop(event_tx);
|
||||
let _ = event_task.await;
|
||||
deadline_task.abort();
|
||||
hangup_task.abort();
|
||||
return Err(describe_error(&err));
|
||||
}
|
||||
};
|
||||
|
||||
let elapsed_headers = started_at.elapsed().as_millis() as u64;
|
||||
let head = Frame::Response {
|
||||
status: response.status,
|
||||
status_reason: response.status_reason.clone(),
|
||||
url: response.url.clone(),
|
||||
remote_addr: response.remote_addr.clone(),
|
||||
version: response.version.clone(),
|
||||
headers: to_wire_headers(&response.headers),
|
||||
request_headers: to_wire_headers(&response.request_headers),
|
||||
content_length: response.content_length,
|
||||
elapsed_headers,
|
||||
elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
|
||||
};
|
||||
write_frame(&frames, &head).await.map_err(|_| "Client went away".to_string())?;
|
||||
|
||||
let declared_length = response.content_length;
|
||||
let mut body = response
|
||||
.into_body_stream()
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
let mut buf = vec![0u8; BODY_READ_CHUNK];
|
||||
let mut total: usize = 0;
|
||||
let mut cancel_rx = cancel_rx;
|
||||
let base64 = base64::engine::general_purpose::STANDARD;
|
||||
|
||||
let read_result: Result<(), String> = loop {
|
||||
if *cancel_rx.borrow() {
|
||||
break Err("Request canceled".to_string());
|
||||
}
|
||||
let read = tokio::select! {
|
||||
biased;
|
||||
_ = cancel_rx.changed() => break Err("Request canceled".to_string()),
|
||||
r = body.read(&mut buf) => r,
|
||||
};
|
||||
match read {
|
||||
Ok(0) => break Ok(()),
|
||||
Ok(n) => {
|
||||
total += n;
|
||||
if total > limits.max_response_bytes {
|
||||
break Err(format!(
|
||||
"Response body exceeds this server's limit of {} bytes",
|
||||
limits.max_response_bytes
|
||||
));
|
||||
}
|
||||
let frame = Frame::Body { data: base64.encode(&buf[..n]) };
|
||||
if write_frame(&frames, &frame).await.is_err() {
|
||||
break Err("Client went away".to_string());
|
||||
}
|
||||
}
|
||||
Err(e) => break Err(format!("Failed to read response body: {e}")),
|
||||
}
|
||||
};
|
||||
drop(body);
|
||||
|
||||
// Let the timeline drain before the terminal frame, so nothing arrives after "done".
|
||||
drop(event_tx);
|
||||
let _ = event_task.await;
|
||||
deadline_task.abort();
|
||||
hangup_task.abort();
|
||||
|
||||
read_result?;
|
||||
Ok(DoneStats {
|
||||
elapsed: started_at.elapsed().as_millis() as u64,
|
||||
content_length: total as u64,
|
||||
content_length_compressed: declared_length.unwrap_or(total as u64),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A send error as a sentence, not a debug dump.
|
||||
///
|
||||
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
|
||||
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
|
||||
/// desktop shows the outer `Debug`; a stranger reading its reply deserves the reason.
|
||||
fn describe_error(err: &yaak_http::error::Error) -> String {
|
||||
match err {
|
||||
yaak_http::error::Error::Client(e) => {
|
||||
let mut leaf: &dyn std::error::Error = e;
|
||||
while let Some(next) = leaf.source() {
|
||||
leaf = next;
|
||||
}
|
||||
let outer = e.to_string();
|
||||
let inner = leaf.to_string();
|
||||
if inner == outer { outer } else { format!("{outer}: {inner}") }
|
||||
}
|
||||
yaak_http::error::Error::RequestError(message) => format!("Request failed: {message}"),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
struct DoneStats {
|
||||
elapsed: u64,
|
||||
content_length: u64,
|
||||
content_length_compressed: u64,
|
||||
}
|
||||
|
||||
fn to_wire_headers(headers: &[(String, String)]) -> Vec<HttpResponseHeader> {
|
||||
headers
|
||||
.iter()
|
||||
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
|
||||
let mut line = match serde_json::to_vec(frame) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to serialize frame: {e}");
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
line.push(b'\n');
|
||||
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Log a finished send at info: destination, outcome, and how long, never the content.
|
||||
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
|
||||
info!("{description} -> {outcome} in {:?}", started.elapsed());
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! What crosses the wire between a tab and this server.
|
||||
//!
|
||||
//! One `POST /v1/http/send` carries a request the tab has already rendered —
|
||||
//! templates resolved, inheritance applied — plus the send settings and the
|
||||
//! cookies the send starts with. The reply is a stream of newline-delimited
|
||||
//! JSON frames: timeline events as they happen, the response head as soon as
|
||||
//! headers arrive, body chunks as they are read, and one terminal frame.
|
||||
//!
|
||||
//! Nothing here names a workspace, a request id, or a response id. The server
|
||||
//! does not know what the tab will call this response; it only knows what came
|
||||
//! back.
|
||||
//!
|
||||
//! The TypeScript side of this contract is generated from these types into
|
||||
//! `bindings/` (`cargo test -p yaak-web`) and published to the tab as
|
||||
//! `@yaakapp-internal/web`, so a change here is a type error there.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
use yaak_models::models::{
|
||||
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
|
||||
};
|
||||
|
||||
/// The body of `POST /v1/http/send`.
|
||||
#[derive(Deserialize, Debug, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_web.ts")]
|
||||
pub struct SendRequest {
|
||||
/// The request to send, in the desktop's own model shape but with every template already
|
||||
/// rendered by the tab. The server builds the URL, headers and body from it exactly the way
|
||||
/// the desktop does after rendering.
|
||||
pub request: HttpRequest,
|
||||
/// The resolved settings, values only. Where they came from is the tab's to record in
|
||||
/// its timeline; the server only needs to obey them.
|
||||
pub settings: HttpSendSettings,
|
||||
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
|
||||
#[serde(default)]
|
||||
pub cookies: Option<Vec<Cookie>>,
|
||||
}
|
||||
|
||||
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
|
||||
/// camelCase like every model the tab stores.
|
||||
#[derive(Serialize, Debug, TS)]
|
||||
#[serde(
|
||||
tag = "type",
|
||||
rename_all = "snake_case",
|
||||
rename_all_fields = "camelCase"
|
||||
)]
|
||||
#[ts(export, export_to = "gen_web.ts")]
|
||||
pub enum Frame {
|
||||
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
|
||||
/// else in the order the engine produced it.
|
||||
Event { event: HttpResponseEventData },
|
||||
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
|
||||
/// the body — so the tab can show status and headers while the body streams.
|
||||
Response {
|
||||
status: u16,
|
||||
status_reason: Option<String>,
|
||||
/// The URL that answered, after redirects.
|
||||
url: String,
|
||||
remote_addr: Option<String>,
|
||||
version: Option<String>,
|
||||
headers: Vec<HttpResponseHeader>,
|
||||
/// The headers that were actually sent on the final hop, cookies and all.
|
||||
request_headers: Vec<HttpResponseHeader>,
|
||||
/// `Content-Length` as declared by the server, if it declared one.
|
||||
#[ts(type = "number | null")]
|
||||
content_length: Option<u64>,
|
||||
/// Milliseconds from the start of the send to the response head.
|
||||
#[ts(type = "number")]
|
||||
elapsed_headers: u64,
|
||||
/// Milliseconds spent in DNS on the last lookup, or zero.
|
||||
#[ts(type = "number")]
|
||||
elapsed_dns: u64,
|
||||
},
|
||||
/// A piece of the response body, decompressed, base64-encoded.
|
||||
Body { data: String },
|
||||
/// The send finished. The last frame on a successful stream.
|
||||
Done {
|
||||
/// Milliseconds from the start of the send to the end of the body.
|
||||
#[ts(type = "number")]
|
||||
elapsed: u64,
|
||||
/// Bytes of body relayed, after decompression.
|
||||
#[ts(type = "number")]
|
||||
content_length: u64,
|
||||
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
|
||||
#[ts(type = "number")]
|
||||
content_length_compressed: u64,
|
||||
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
|
||||
cookies: Option<Vec<Cookie>>,
|
||||
},
|
||||
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
|
||||
/// still come back — the transaction may have set some before the hop that failed.
|
||||
Error {
|
||||
message: String,
|
||||
cookies: Option<Vec<Cookie>>,
|
||||
},
|
||||
}
|
||||
@@ -88,6 +88,7 @@ yaak-grpc = { workspace = true }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-license = { workspace = true, optional = true }
|
||||
yaak-mac-window = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
yaak-plugins = { workspace = true }
|
||||
yaak-sse = { workspace = true }
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use tauri::{Runtime, State, WebviewWindow};
|
||||
use yaak_plugins::events::GetThemesResponse;
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
|
||||
pub(crate) async fn cmd_get_themes<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(plugin_manager.get_themes(&window.plugin_context()).await?)
|
||||
}
|
||||
@@ -2,7 +2,6 @@ use std::collections::BTreeMap;
|
||||
|
||||
use crate::PluginContextExt;
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use KeyAndValueRef::{Ascii, Binary};
|
||||
use tauri::{Manager, Runtime, WebviewWindow};
|
||||
use yaak_grpc::{KeyAndValueRef, MetadataMap};
|
||||
@@ -21,22 +20,6 @@ pub(crate) fn metadata_to_map(metadata: MetadataMap) -> BTreeMap<String, String>
|
||||
entries
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_grpc_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &GrpcRequest,
|
||||
) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let metadata = window.db().resolve_metadata_for_grpc_request(request)?;
|
||||
new_request.metadata = metadata;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub(crate) async fn build_metadata<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
|
||||
@@ -179,19 +179,3 @@ async fn send_http_request_inner<R: Runtime>(
|
||||
Ok(SentHttpRequest { response: result.response, body: result.response_body })
|
||||
}
|
||||
|
||||
pub fn resolve_http_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &HttpRequest,
|
||||
) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_http_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
@@ -2,18 +2,17 @@ extern crate core;
|
||||
use crate::encoding::read_response_body;
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use crate::grpc::{build_metadata, metadata_to_map, resolve_grpc_request};
|
||||
use crate::http_request::{resolve_http_request, send_http_request};
|
||||
use crate::grpc::{build_metadata, metadata_to_map};
|
||||
use crate::http_request::send_http_request;
|
||||
use crate::import::{import_data, import_url};
|
||||
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
|
||||
use crate::notifications::YaakNotifier;
|
||||
use crate::render::{render_grpc_request, render_json_value, render_template};
|
||||
use crate::render::{render_grpc_request, render_template};
|
||||
use crate::updates::{UpdateMode, UpdateTrigger, YaakUpdater};
|
||||
use crate::uri_scheme::handle_deep_link;
|
||||
use error::Result as YaakResult;
|
||||
use eventsource_client::{EventParser, SSE};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
@@ -31,26 +30,20 @@ use tokio::task::block_in_place;
|
||||
use tokio::time;
|
||||
use yaak::send::ResponseBody;
|
||||
use yaak_commands::responses::locate_response_body;
|
||||
use yaak_commands::resolve::resolve_grpc_request;
|
||||
use yaak_common::command::new_checked_command;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||
use yaak_grpc::{Code, ServiceDefinition};
|
||||
use yaak_mac_window::AppHandleMacWindowExt;
|
||||
use yaak_models::models::{
|
||||
AnyModel, CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
|
||||
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
|
||||
};
|
||||
use yaak_models::util::{BatchUpsertResult, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, Color, FilterResponse, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, InternalEvent,
|
||||
InternalEventPayload, JsonPrimitive, PluginContext, RenderPurpose, ShowToastRequest,
|
||||
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
|
||||
RenderPurpose, ShowToastRequest,
|
||||
};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
@@ -58,10 +51,9 @@ use yaak_rpc_schema::{AppMetaData, EphemeralHttpResponse};
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_templates::strip_json_comments::strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, Tokens, transform_args};
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
|
||||
mod commands;
|
||||
mod encoding;
|
||||
mod error;
|
||||
mod feedback;
|
||||
@@ -76,6 +68,7 @@ mod notifications;
|
||||
mod plugin_events;
|
||||
mod plugins_ext;
|
||||
mod render;
|
||||
mod restart;
|
||||
mod rpc_ext;
|
||||
mod sync_ext;
|
||||
mod updates;
|
||||
@@ -220,56 +213,6 @@ async fn detect_cli_version_for_binary(program: &str) -> Option<String> {
|
||||
Some(parts.next().unwrap_or(line).to_string())
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
tokens: Tokens,
|
||||
) -> YaakResult<String> {
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
let new_tokens = transform_args(tokens, &cb)?;
|
||||
Ok(new_tokens.to_string())
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
template: &str,
|
||||
workspace_id: &str,
|
||||
environment_id: Option<&str>,
|
||||
purpose: Option<RenderPurpose>,
|
||||
ignore_error: Option<bool>,
|
||||
) -> YaakResult<String> {
|
||||
let environment_chain =
|
||||
app_handle.db().resolve_environments(workspace_id, None, environment_id)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let result = render_template(
|
||||
template,
|
||||
environment_chain,
|
||||
&PluginTemplateCallback::new(
|
||||
plugin_manager,
|
||||
encryption_manager,
|
||||
&PluginContext::new(Some(window.label().to_string()), window.workspace_id()),
|
||||
purpose.unwrap_or(RenderPurpose::Preview),
|
||||
),
|
||||
&RenderOptions {
|
||||
error_behavior: match ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
feature: String,
|
||||
@@ -296,7 +239,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<Vec<ServiceDefinition>> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
@@ -356,7 +300,8 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
grpc_handle: State<'_, Mutex<GrpcHandle>>,
|
||||
) -> YaakResult<String> {
|
||||
let unrendered_request = app_handle.db().get_grpc_request(request_id)?;
|
||||
let (resolved_request, auth_context_id) = resolve_grpc_request(&window, &unrendered_request)?;
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_grpc_request(&window.db(), &unrendered_request)?;
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&unrendered_request.workspace_id,
|
||||
unrendered_request.folder_id.as_deref(),
|
||||
@@ -960,7 +905,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||
app_handle.request_restart();
|
||||
restart::request_restart(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1080,283 +1025,19 @@ async fn cmd_import_url<R: Runtime>(
|
||||
import_url(&window, url).await
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_http_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_websocket_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let websocket_request = window.db().get_websocket_request(&req.args.websocket_request.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_websocket_request_action(
|
||||
&window.plugin_context(),
|
||||
CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(plugin_manager.get_workspace_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallWorkspaceActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let workspace = window.db().get_workspace(&req.args.workspace.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_workspace_action(
|
||||
&window.plugin_context(),
|
||||
CallWorkspaceActionRequest { args: CallWorkspaceActionArgs { workspace }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetFolderActionsResponse>> {
|
||||
Ok(plugin_manager.get_folder_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallFolderActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
let folder = window.db().get_folder(&req.args.folder.id)?;
|
||||
Ok(plugin_manager
|
||||
.call_folder_action(
|
||||
&window.plugin_context(),
|
||||
CallFolderActionRequest { args: CallFolderActionArgs { folder }, ..req },
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(plugin_manager.get_grpc_request_actions(&window.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
let results = plugin_manager.get_template_function_summaries(&window.plugin_context()).await?;
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
_environment_id: Option<&str>,
|
||||
) -> YaakResult<GetTemplateFunctionConfigResponse> {
|
||||
Ok(plugin_manager
|
||||
.get_template_function_config(&window.plugin_context(), function_name, values, model.id())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results =
|
||||
plugin_manager.get_http_authentication_summaries(&window.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<GetHttpAuthenticationConfigResponse> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication config".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values for token lookup
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Preview,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::return_empty())
|
||||
.await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.get_http_authentication_config(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
rendered_values,
|
||||
model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallHttpRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_http_request_action(
|
||||
&window.plugin_context(),
|
||||
CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs {
|
||||
http_request: resolve_http_request(&window, &req.args.http_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<()> {
|
||||
Ok(plugin_manager
|
||||
.call_grpc_request_action(
|
||||
&window.plugin_context(),
|
||||
CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs {
|
||||
grpc_request: resolve_grpc_request(&window, &req.args.grpc_request)?.0,
|
||||
..req.args
|
||||
},
|
||||
..req
|
||||
},
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
app_handle: AppHandle<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
encryption_manager: State<'_, EncryptionManager>,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model: AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
) -> YaakResult<()> {
|
||||
// Extract workspace_id and folder_id from the model to resolve the environment chain
|
||||
let (workspace_id, folder_id) = match &model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
_ => return Err(GenericError("Unsupported model type for authentication action".into())),
|
||||
};
|
||||
|
||||
// Resolve environment chain and render the values
|
||||
let environment_chain = app_handle.db().resolve_environments(
|
||||
&workspace_id,
|
||||
folder_id.as_deref(),
|
||||
environment_id,
|
||||
)?;
|
||||
let plugin_manager_arc = Arc::new((*plugin_manager).clone());
|
||||
let encryption_manager_arc = Arc::new((*encryption_manager).clone());
|
||||
let cb = PluginTemplateCallback::new(
|
||||
plugin_manager_arc,
|
||||
encryption_manager_arc,
|
||||
&window.plugin_context(),
|
||||
RenderPurpose::Send,
|
||||
);
|
||||
|
||||
// Convert HashMap<String, JsonPrimitive> to serde_json::Value for rendering
|
||||
let values_json: serde_json::Value = serde_json::to_value(&values)?;
|
||||
let rendered_json =
|
||||
render_json_value(values_json, environment_chain, &cb, &RenderOptions::throw()).await?;
|
||||
|
||||
// Convert back to HashMap<String, JsonPrimitive>
|
||||
let rendered_values: HashMap<String, JsonPrimitive> = serde_json::from_value(rendered_json)?;
|
||||
|
||||
Ok(plugin_manager
|
||||
.call_http_authentication_action(
|
||||
&window.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
rendered_values,
|
||||
&model.id(),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
command: &str,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
workspace_id: &str,
|
||||
) -> YaakResult<HttpRequest> {
|
||||
let import_result = plugin_manager.import_data(&window.plugin_context(), command).await?;
|
||||
|
||||
Ok(import_result
|
||||
.resources
|
||||
.http_requests
|
||||
.get(0)
|
||||
.ok_or(GenericError("No curl command found".to_string()))
|
||||
.map(|r| {
|
||||
let mut request = r.clone();
|
||||
request.workspace_id = workspace_id.into();
|
||||
request.id = "".to_string();
|
||||
request
|
||||
})?)
|
||||
}
|
||||
|
||||
/// Decodes base64 and writes the bytes to a file the user picked.
|
||||
///
|
||||
@@ -1452,17 +1133,6 @@ async fn cmd_send_http_request<R: Runtime>(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(
|
||||
app_handle: AppHandle<R>,
|
||||
window: WebviewWindow<R>,
|
||||
plugin_manager: State<'_, PluginManager>,
|
||||
) -> YaakResult<Vec<(String, String)>> {
|
||||
let plugins = app_handle.db().list_plugins()?;
|
||||
let plugin_context =
|
||||
PluginContext::new(Some(window.label().to_string()), window.workspace_id());
|
||||
let errors = plugin_manager.initialize_all_plugins(plugins, &plugin_context).await;
|
||||
Ok(errors)
|
||||
}
|
||||
|
||||
async fn cmd_new_child_window<R: Runtime>(
|
||||
parent_window: WebviewWindow<R>,
|
||||
@@ -1589,6 +1259,14 @@ pub fn run() {
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
let lifecycle_host = yaak_lifecycle::Host::owner()
|
||||
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
|
||||
if let Err(e) =
|
||||
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
|
||||
{
|
||||
error!("on_launch hook failed: {e:?}");
|
||||
}
|
||||
|
||||
// The RPC command registry — every frontend command dispatches
|
||||
// through this via the single `rpc` Tauri command
|
||||
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
|
||||
@@ -1688,15 +1366,6 @@ pub fn run() {
|
||||
let info = history::get_or_upsert_launch_info(&h);
|
||||
debug!("Launched Yaak {:?}", info);
|
||||
});
|
||||
|
||||
// Cancel pending requests
|
||||
let h = app_handle.clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
let db = h.db();
|
||||
let _ = db.cancel_pending_http_responses();
|
||||
let _ = db.cancel_pending_grpc_connections();
|
||||
let _ = db.cancel_pending_websocket_connections();
|
||||
});
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
@@ -1740,6 +1409,7 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
}
|
||||
RunEvent::Exit => restart::relaunch_if_requested(),
|
||||
_ => {}
|
||||
};
|
||||
});
|
||||
@@ -1782,6 +1452,7 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
|
||||
let ev = match ev {
|
||||
Ok(Some(ev)) => ev,
|
||||
// Nothing to say, or the reply comes later from somewhere else.
|
||||
Ok(None) => return,
|
||||
Err(e) => {
|
||||
warn!("Failed to handle plugin event: {e:?}");
|
||||
@@ -1794,7 +1465,10 @@ fn monitor_plugin_events<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
timeout: Some(30000),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
// Tell the plugin as well as the user. It is awaiting a
|
||||
// reply, and a toast it cannot see would leave it
|
||||
// waiting for one that never comes.
|
||||
InternalEventPayload::ErrorResponse(ErrorResponse { error: e.to_string() })
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ use yaak_models::error::Result;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
|
||||
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
|
||||
|
||||
@@ -152,30 +151,11 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
}
|
||||
};
|
||||
|
||||
let db = query_manager.connect();
|
||||
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
|
||||
{
|
||||
error!("Failed to prune model_changes rows on startup: {err:?}");
|
||||
}
|
||||
// Only stream writes that happen after this app launch.
|
||||
let cursor = ModelChangeCursor::from_launch_time();
|
||||
|
||||
let poll_query_manager = query_manager.clone();
|
||||
|
||||
// GC response bodies orphaned by cascade deletes, which historically
|
||||
// didn't clean the blob DB or responses directory
|
||||
let gc_query_manager = query_manager.clone();
|
||||
let gc_blob_manager = blob_manager.clone();
|
||||
let gc_responses_dir = app_path.join("responses");
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let db = gc_query_manager.connect();
|
||||
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
|
||||
Ok(0) => {}
|
||||
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
|
||||
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
app_handle.manage(query_manager);
|
||||
app_handle.manage(blob_manager);
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ use crate::{
|
||||
call_frontend, cookie_jar_from_window, environment_from_window, get_window_from_plugin_context,
|
||||
workspace_from_window,
|
||||
};
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use chrono::Utc;
|
||||
use log::error;
|
||||
use std::sync::Arc;
|
||||
@@ -16,6 +18,7 @@ use tauri_plugin_opener::OpenerExt;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::response_body::FileResponseBodyStore;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_http::cookies::get_cookie_value_from_jar;
|
||||
use yaak_models::models::{HttpResponse, Plugin};
|
||||
@@ -54,6 +57,7 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
||||
|
||||
match handle_shared_plugin_event(
|
||||
app_handle.db_manager().inner(),
|
||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
||||
&event.payload,
|
||||
SharedPluginEventContext {
|
||||
plugin_name: &plugin_name,
|
||||
@@ -313,8 +317,13 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
)
|
||||
.await?;
|
||||
|
||||
// An ad-hoc request saves nothing, so the engine hands the body
|
||||
// back and this reply is the only place the plugin can get it.
|
||||
let body = http_response.body.returned_bytes().map(|b| BASE64_STANDARD.encode(b));
|
||||
|
||||
Ok(Some(InternalEventPayload::SendHttpRequestResponse(SendHttpRequestResponse {
|
||||
http_response: http_response.response,
|
||||
body,
|
||||
})))
|
||||
}
|
||||
HostRequest::OpenWindow(req) => {
|
||||
|
||||
@@ -1,25 +1,8 @@
|
||||
use serde_json::Value;
|
||||
pub use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
//! One import path for rendering, wherever the pieces actually live.
|
||||
//!
|
||||
//! The request renderers are engine code; the template renderers moved to
|
||||
//! `yaak-commands` when the template commands did. Callers in this crate do not
|
||||
//! need to track which is which.
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, &opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use log::{error, info};
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
static RELAUNCH_WITH_LAUNCH_SERVICES: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Restart the app without directly spawning the executable on macOS.
|
||||
///
|
||||
/// Tauri's current macOS restart path starts the executable from the dying
|
||||
/// process. Besides inheriting stale process state, that bypasses
|
||||
/// LaunchServices and can leave the replacement app running without an active
|
||||
/// window. Defer the relaunch until `RunEvent::Exit`, when the event loop is
|
||||
/// already shutting down, and hand it to LaunchServices instead.
|
||||
pub fn request_restart<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if current_app_bundle().is_some() {
|
||||
info!("Requesting restart through macOS LaunchServices");
|
||||
RELAUNCH_WITH_LAUNCH_SERVICES.store(true, Ordering::SeqCst);
|
||||
app_handle.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
app_handle.request_restart();
|
||||
}
|
||||
|
||||
/// Complete a pending macOS restart after Tauri has emitted its exit events.
|
||||
pub fn relaunch_if_requested() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !RELAUNCH_WITH_LAUNCH_SERVICES.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(bundle) = current_app_bundle() else {
|
||||
error!("Failed to resolve the app bundle for restart");
|
||||
return;
|
||||
};
|
||||
|
||||
match Command::new("/usr/bin/open")
|
||||
.arg("-n")
|
||||
.arg(&bundle)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => info!("Relaunching {} through LaunchServices", bundle.display()),
|
||||
Err(error) => error!("Failed to relaunch through LaunchServices: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn current_app_bundle() -> Option<PathBuf> {
|
||||
app_bundle_from_executable(&std::env::current_exe().ok()?)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn app_bundle_from_executable(executable: &Path) -> Option<PathBuf> {
|
||||
let macos_dir = executable.parent()?;
|
||||
if macos_dir.file_name()? != "MacOS" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents_dir = macos_dir.parent()?;
|
||||
if contents_dir.file_name()? != "Contents" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bundle = contents_dir.parent()?;
|
||||
if bundle.extension()? != "app" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(bundle.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::app_bundle_from_executable;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn resolves_macos_app_bundle() {
|
||||
assert_eq!(
|
||||
app_bundle_from_executable(Path::new(
|
||||
"/Applications/Yaak.app/Contents/MacOS/yaak-app-client"
|
||||
)),
|
||||
Some(PathBuf::from("/Applications/Yaak.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unbundled_executable() {
|
||||
assert_eq!(
|
||||
app_bundle_from_executable(Path::new("/workspace/target/debug/yaak-app-client")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ use crate::updates::YaakUpdater;
|
||||
use log::warn;
|
||||
use serde::Serialize;
|
||||
use tauri::{Manager, Runtime, State, WebviewWindow};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
@@ -41,7 +42,9 @@ use yaak_models::models::{
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_plugins::events::{
|
||||
FilterResponse, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
|
||||
JsonPrimitive, RenderPurpose, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse,
|
||||
GetHttpRequestActionsResponse, GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse, GetThemesResponse, GetWebsocketRequestActionsResponse,
|
||||
@@ -50,11 +53,13 @@ use yaak_plugins::events::{
|
||||
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
|
||||
use yaak_plugins::manager::PluginManager;
|
||||
use yaak_plugins::native_template_functions::encrypt_secure_template_function;
|
||||
use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc::RpcRouter;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_sse::sse::ServerSentEvent;
|
||||
use yaak_sync::sync::SyncOp;
|
||||
use yaak_templates::TemplateCallback;
|
||||
use yaak_tauri_utils::window::WorkspaceWindowTrait;
|
||||
use yaak_ws::WebsocketManager;
|
||||
|
||||
@@ -103,27 +108,177 @@ impl<R: Runtime> Host for ClientCtx<R> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> ClientCtx<R> {
|
||||
/// The plugin runtime this window talks to. Only the `PluginHost` impl
|
||||
/// below uses it; everything else goes through the trait.
|
||||
fn pm(&self) -> State<'_, PluginManager> {
|
||||
self.window.state::<PluginManager>()
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop answers all of these out of the `PluginManager` it already
|
||||
/// runs — the Node sidecar. Each is a delegation, which is the point: the
|
||||
/// operations are what the handlers need, and this is one host's way of
|
||||
/// providing them.
|
||||
impl<R: Runtime> PluginHost for ClientCtx<R> {
|
||||
async fn loaded_plugin_metadata(&self, directory: &str) -> Option<PluginMetadata> {
|
||||
let manager = self.window.state::<PluginManager>();
|
||||
let handle = manager.get_plugin_by_dir(directory).await?;
|
||||
let handle = self.pm().get_plugin_by_dir(directory).await?;
|
||||
Some(handle.info())
|
||||
}
|
||||
|
||||
async fn take_plugin_init_errors(&self) -> Vec<(String, String)> {
|
||||
self.window.state::<PluginManager>().take_init_errors().await
|
||||
self.pm().take_init_errors().await
|
||||
}
|
||||
|
||||
async fn resolve_plugins(&self, plugins: Vec<Plugin>) -> Vec<Plugin> {
|
||||
self.window.state::<PluginManager>().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
self.pm().resolve_plugins_for_runtime_from_db(plugins).await
|
||||
}
|
||||
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
PluginTemplateCallback::new(
|
||||
Arc::new((*self.pm()).clone()),
|
||||
Arc::new(self.encryption_manager().clone()),
|
||||
&self.plugin_context(),
|
||||
purpose,
|
||||
)
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_summaries(&self.plugin_context())
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(self
|
||||
.window
|
||||
.state::<PluginManager>()
|
||||
.get_template_function_config(&self.plugin_context(), function_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(self.pm().get_themes(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(self.pm().get_http_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(self.pm().get_websocket_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(self.pm().get_grpc_request_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(self.pm().get_workspace_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(self.pm().get_folder_actions(&self.plugin_context()).await?)
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_http_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_grpc_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_websocket_request_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_workspace_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self.pm().call_folder_action(&self.plugin_context(), req).await?)
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
let results = self.pm().get_http_authentication_summaries(&self.plugin_context()).await?;
|
||||
Ok(results.into_iter().map(|(_, a)| a).collect())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.get_http_authentication_config(&self.plugin_context(), auth_name, values, model_id)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Ok(self
|
||||
.pm()
|
||||
.call_http_authentication_action(
|
||||
&self.plugin_context(),
|
||||
auth_name,
|
||||
action_index,
|
||||
values,
|
||||
model_id,
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn import_data(&self, content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Ok(self.pm().import_data(&self.plugin_context(), content).await?)
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
self.pm().initialize_all_plugins(plugins, &self.plugin_context()).await
|
||||
}
|
||||
|
||||
async fn encrypt_secure_template(&self, template: &str) -> yaak_commands::Result<String> {
|
||||
let plugin_manager = Arc::new((*self.window.state::<PluginManager>()).clone());
|
||||
let plugin_manager = Arc::new((*self.pm()).clone());
|
||||
let encryption_manager = Arc::new(self.encryption_manager().clone());
|
||||
Ok(encrypt_secure_template_function(
|
||||
plugin_manager,
|
||||
@@ -227,11 +382,11 @@ async fn cmd_metadata<R: Runtime>(ctx: ClientCtx<R>, _req: CmdMetadataReq) -> Re
|
||||
}
|
||||
|
||||
async fn cmd_template_tokens_to_string<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateTokensToStringReq) -> Result<String> {
|
||||
Ok(crate::cmd_template_tokens_to_string(ctx.window.clone(), ctx.window.app_handle().clone(), req.tokens).await?)
|
||||
Ok(yaak_commands::templates::cmd_template_tokens_to_string(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_render_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdRenderTemplateReq) -> Result<String> {
|
||||
Ok(crate::cmd_render_template(ctx.window.clone(), ctx.window.app_handle().clone(), &req.template, &req.workspace_id, req.environment_id.as_deref(), req.purpose, req.ignore_error).await?)
|
||||
Ok(yaak_commands::templates::cmd_render_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_send_feedback<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendFeedbackReq) -> Result<()> {
|
||||
@@ -294,68 +449,68 @@ async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) ->
|
||||
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
|
||||
}
|
||||
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(crate::cmd_http_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_http_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(crate::cmd_websocket_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_websocket_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWebsocketRequestActionsReq) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_websocket_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_websocket_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWebsocketRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_websocket_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_websocket_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(crate::cmd_workspace_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_workspace_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdWorkspaceActionsReq) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_workspace_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_workspace_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallWorkspaceActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_workspace_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_workspace_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(crate::cmd_folder_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_folder_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdFolderActionsReq) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_folder_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_folder_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallFolderActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_folder_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_folder_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(crate::cmd_grpc_request_actions(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_grpc_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdGrpcRequestActionsReq) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(yaak_commands::actions::cmd_grpc_request_actions(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(crate::cmd_template_function_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_template_function_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionSummariesReq) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_template_function_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_template_function_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdTemplateFunctionConfigReq) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
Ok(crate::cmd_template_function_config(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.function_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::templates::cmd_template_function_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(crate::cmd_get_http_authentication_summaries(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_get_http_authentication_summaries<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationSummariesReq) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_summaries(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_http_authentication_config<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpAuthenticationConfigReq) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
Ok(crate::cmd_get_http_authentication_config(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::auth::cmd_get_http_authentication_config(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_http_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_http_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_grpc_request_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallGrpcRequestActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_grpc_request_action(ctx.window.clone(), req.req, ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
Ok(yaak_commands::actions::cmd_call_grpc_request_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_call_http_authentication_action<R: Runtime>(ctx: ClientCtx<R>, req: CmdCallHttpAuthenticationActionReq) -> Result<()> {
|
||||
Ok(crate::cmd_call_http_authentication_action(ctx.window.clone(), ctx.window.app_handle().clone(), ctx.window.app_handle().state::<PluginManager>(), ctx.window.app_handle().state::<EncryptionManager>(), &req.auth_name, req.action_index, req.values, req.model, req.environment_id.as_deref()).await?)
|
||||
Ok(yaak_commands::auth::cmd_call_http_authentication_action(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_curl_to_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdCurlToRequestReq) -> Result<HttpRequest> {
|
||||
Ok(crate::cmd_curl_to_request(ctx.window.clone(), &req.command, ctx.window.app_handle().state::<PluginManager>(), &req.workspace_id).await?)
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
|
||||
@@ -374,8 +529,8 @@ async fn cmd_send_http_request<R: Runtime>(ctx: ClientCtx<R>, req: CmdSendHttpRe
|
||||
Ok(crate::cmd_send_http_request(ctx.window.app_handle().clone(), ctx.window.clone(), req.environment_id.as_deref(), req.cookie_jar_id.as_deref(), req.request_id).await?)
|
||||
}
|
||||
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, _req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(crate::cmd_reload_plugins(ctx.window.app_handle().clone(), ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_reload_plugins<R: Runtime>(ctx: ClientCtx<R>, req: CmdReloadPluginsReq) -> Result<Vec<(String, String)>> {
|
||||
Ok(yaak_commands::actions::cmd_reload_plugins(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_plugin_info<R: Runtime>(ctx: ClientCtx<R>, req: CmdPluginInfoReq) -> Result<PluginMetadata> {
|
||||
@@ -418,8 +573,8 @@ async fn cmd_secure_template<R: Runtime>(ctx: ClientCtx<R>, req: CmdSecureTempla
|
||||
Ok(yaak_commands::encryption::cmd_secure_template(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, _req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(crate::commands::cmd_get_themes(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>()).await?)
|
||||
async fn cmd_get_themes<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetThemesReq) -> Result<Vec<GetThemesResponse>> {
|
||||
Ok(yaak_commands::templates::cmd_get_themes(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_enable_encryption<R: Runtime>(ctx: ClientCtx<R>, req: CmdEnableEncryptionReq) -> Result<()> {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::restart;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
@@ -332,7 +333,7 @@ async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Upd
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
window.app_handle().request_restart();
|
||||
restart::request_restart(window.app_handle());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ use yaak_http::cookies::CookieStore;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
HttpResponseHeader, WebsocketConnection, WebsocketConnectionState, WebsocketEvent,
|
||||
WebsocketEventType, WebsocketRequest,
|
||||
WebsocketEventType,
|
||||
};
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::{CallHttpAuthenticationRequest, HttpHeader, RenderPurpose};
|
||||
@@ -27,6 +27,7 @@ use yaak_plugins::template_callback::PluginTemplateCallback;
|
||||
use yaak_templates::strip_json_comments::maybe_strip_json_comments;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions};
|
||||
use yaak_tls::find_client_certificate;
|
||||
use yaak_commands::resolve::resolve_websocket_request;
|
||||
use yaak_ws::{WebsocketManager, render_websocket_request};
|
||||
|
||||
pub async fn cmd_ws_send<R: Runtime>(
|
||||
@@ -75,7 +76,7 @@ async fn send_websocket_message<R: Runtime>(
|
||||
environment_id,
|
||||
)?;
|
||||
let (resolved_request, _auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -154,7 +155,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
app_handle.db().resolve_settings_for_websocket_request(&unrendered_request)?;
|
||||
let settings = app_handle.db().get_settings();
|
||||
let (resolved_request, auth_context_id) =
|
||||
resolve_websocket_request(&window, &unrendered_request)?;
|
||||
resolve_websocket_request(&window.db(), &unrendered_request)?;
|
||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||
let request = render_websocket_request(
|
||||
@@ -454,23 +455,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
/// Resolve inherited authentication and headers for a websocket request
|
||||
fn resolve_websocket_request<R: Runtime>(
|
||||
window: &WebviewWindow<R>,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
window.db().resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
let headers = window.db().resolve_headers_for_websocket_request(request)?;
|
||||
new_request.headers = headers;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
/// Convert WS URL to HTTP URL for cookie filtering
|
||||
/// WebSocket upgrade requests are HTTP requests initially, so HttpOnly cookies should apply
|
||||
|
||||
+12
-1
@@ -55,7 +55,7 @@ urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting
|
||||
|
||||
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
|
||||
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, bodyPath: string | null, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
|
||||
|
||||
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
|
||||
|
||||
@@ -70,6 +70,17 @@ export type HttpResponseHeader = { name: string, value: string, };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
|
||||
|
||||
export type HttpUrlParameter = { enabled?: boolean,
|
||||
/**
|
||||
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
|
||||
@@ -6,10 +6,8 @@ authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt"] }
|
||||
yaak = { workspace = true }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-crypto = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! The actions plugins contribute to the UI, and the calls that run them.
|
||||
//!
|
||||
//! Listing is a plain question for the plugin runtime. Calling is not: the
|
||||
//! frontend sends back the model it was showing, and a plugin must act on what
|
||||
//! that model *actually is* — re-read from the database, with inheritance
|
||||
//! resolved — not on a snapshot the UI has been holding. That re-reading is the
|
||||
//! work these handlers do.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::resolve::{resolve_grpc_request, resolve_http_request};
|
||||
use yaak_models::models::HttpRequest;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionArgs, CallFolderActionRequest, CallGrpcRequestActionArgs,
|
||||
CallGrpcRequestActionRequest, CallHttpRequestActionArgs, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionArgs, CallWebsocketRequestActionRequest, CallWorkspaceActionArgs,
|
||||
CallWorkspaceActionRequest, GetFolderActionsResponse, GetGrpcRequestActionsResponse,
|
||||
GetHttpRequestActionsResponse, GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
|
||||
// -- Listing --
|
||||
|
||||
pub async fn cmd_http_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdHttpRequestActionsReq,
|
||||
) -> Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
host.http_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_websocket_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWebsocketRequestActionsReq,
|
||||
) -> Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
host.websocket_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_grpc_request_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGrpcRequestActionsReq,
|
||||
) -> Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
host.grpc_request_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_workspace_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdWorkspaceActionsReq,
|
||||
) -> Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
host.workspace_actions().await
|
||||
}
|
||||
|
||||
pub async fn cmd_folder_actions<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdFolderActionsReq,
|
||||
) -> Result<Vec<GetFolderActionsResponse>> {
|
||||
host.folder_actions().await
|
||||
}
|
||||
|
||||
// -- Calling --
|
||||
|
||||
pub async fn cmd_call_http_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let http_request = resolve_http_request(&host.db(), &inner.args.http_request)?.0;
|
||||
host.call_http_request_action(CallHttpRequestActionRequest {
|
||||
args: CallHttpRequestActionArgs { http_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_grpc_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallGrpcRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let grpc_request = resolve_grpc_request(&host.db(), &inner.args.grpc_request)?.0;
|
||||
host.call_grpc_request_action(CallGrpcRequestActionRequest {
|
||||
args: CallGrpcRequestActionArgs { grpc_request, ..inner.args },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_websocket_request_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWebsocketRequestActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let websocket_request = host.db().get_websocket_request(&inner.args.websocket_request.id)?;
|
||||
host.call_websocket_request_action(CallWebsocketRequestActionRequest {
|
||||
args: CallWebsocketRequestActionArgs { websocket_request },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_workspace_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallWorkspaceActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let workspace = host.db().get_workspace(&inner.args.workspace.id)?;
|
||||
host.call_workspace_action(CallWorkspaceActionRequest {
|
||||
args: CallWorkspaceActionArgs { workspace },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_folder_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallFolderActionReq,
|
||||
) -> Result<()> {
|
||||
let inner = req.req;
|
||||
let folder = host.db().get_folder(&inner.args.folder.id)?;
|
||||
host.call_folder_action(CallFolderActionRequest {
|
||||
args: CallFolderActionArgs { folder },
|
||||
..inner
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// -- Other things the plugin runtime does --
|
||||
|
||||
/// Turn a `curl` command line into an unsaved request, by handing it to the
|
||||
/// same importer plugins that read files.
|
||||
pub async fn cmd_curl_to_request<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCurlToRequestReq,
|
||||
) -> Result<HttpRequest> {
|
||||
let imported = host.import_data(&req.command).await?;
|
||||
|
||||
let request = imported
|
||||
.resources
|
||||
.http_requests
|
||||
.first()
|
||||
.ok_or_else(|| Error::Generic("No curl command found".to_string()))?;
|
||||
|
||||
// Belongs to the workspace the user is importing into, and is not saved
|
||||
// until they say so — hence the blank id.
|
||||
let mut request = request.clone();
|
||||
request.workspace_id = req.workspace_id;
|
||||
request.id = String::new();
|
||||
Ok(request)
|
||||
}
|
||||
|
||||
/// Restart every plugin, returning whatever failed to come back up.
|
||||
pub async fn cmd_reload_plugins<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdReloadPluginsReq,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
let plugins = host.db().list_plugins()?;
|
||||
Ok(host.reload_plugins(plugins).await)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Authentication config forms and their actions.
|
||||
//!
|
||||
//! Both commands here do the same preparation: the frontend sends the model
|
||||
//! whose auth is being edited plus the values currently in the form, and those
|
||||
//! values may contain templates. They have to be rendered against the model's
|
||||
//! own environment chain before a plugin sees them, or an auth plugin receives
|
||||
//! `${[ api_key ]}` where it expected a key.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_json_value;
|
||||
use std::collections::HashMap;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_plugins::events::{
|
||||
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::RenderOptions;
|
||||
|
||||
pub async fn cmd_get_http_authentication_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetHttpAuthenticationSummariesReq,
|
||||
) -> Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
host.http_authentication_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_http_authentication_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdGetHttpAuthenticationConfigReq,
|
||||
) -> Result<GetHttpAuthenticationConfigResponse> {
|
||||
// A config form is being displayed, so a template that cannot resolve
|
||||
// should show as blank rather than refuse to open the form.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Preview,
|
||||
&RenderOptions::return_empty(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.http_authentication_config(&req.auth_name, values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_call_http_authentication_action<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdCallHttpAuthenticationActionReq,
|
||||
) -> Result<()> {
|
||||
// An action actually uses these values, so an unresolvable template is an
|
||||
// error rather than an empty string that would silently authenticate wrong.
|
||||
let values = render_auth_values(
|
||||
&host,
|
||||
&req.model,
|
||||
req.environment_id.as_deref(),
|
||||
req.values,
|
||||
RenderPurpose::Send,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Render the form's values against the environment chain the model sits in.
|
||||
///
|
||||
/// The chain depends on where the model lives — a request inherits through its
|
||||
/// folder, a workspace has only its own — so the model is what decides which
|
||||
/// variables are in scope.
|
||||
async fn render_auth_values<H: PluginHost>(
|
||||
host: &H,
|
||||
model: &AnyModel,
|
||||
environment_id: Option<&str>,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
purpose: RenderPurpose,
|
||||
options: &RenderOptions,
|
||||
) -> Result<HashMap<String, JsonPrimitive>> {
|
||||
let (workspace_id, folder_id) = match model {
|
||||
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
|
||||
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
|
||||
AnyModel::Workspace(w) => (w.id.clone(), None),
|
||||
other => {
|
||||
return Err(Error::Generic(format!(
|
||||
"Cannot resolve authentication for a {}",
|
||||
other.model()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
|
||||
|
||||
let cb = host.template_callback(purpose);
|
||||
let rendered =
|
||||
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
|
||||
|
||||
Ok(serde_json::from_value(rendered)?)
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
//! is anything only a desktop can do — open a native window, run the updater,
|
||||
//! show a native dialog — those handlers stay with the desktop.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
@@ -21,8 +22,17 @@ use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::Plugin;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
use yaak_plugins::events::PluginContext;
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
PluginContext, RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
/// Only `Clone` is required here. `Send`/`Sync`/`'static` are deliberately
|
||||
/// *not*: a browser host is single-threaded and its connection pool is an
|
||||
@@ -106,6 +116,101 @@ pub trait PluginHost: Host {
|
||||
/// loaded. A host without a runtime can return them untouched.
|
||||
fn resolve_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<Plugin>>;
|
||||
|
||||
/// The template functions this host can run, as a callback the renderer
|
||||
/// drives. This is the *only* thing the plugin runtime uniquely provides to
|
||||
/// a render — the variables come from the environment chain, which is an
|
||||
/// ordinary database read — so handing back the callback keeps the rest of
|
||||
/// rendering shared instead of pushing whole commands behind this trait.
|
||||
fn template_callback(&self, purpose: RenderPurpose) -> impl TemplateCallback;
|
||||
|
||||
/// Every template function the installed plugins expose, for the
|
||||
/// autocomplete menu.
|
||||
fn template_function_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
|
||||
|
||||
/// The form a template function wants to show for the given values.
|
||||
fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetTemplateFunctionConfigResponse>>;
|
||||
|
||||
/// Themes contributed by plugins.
|
||||
fn themes(&self) -> impl Future<Output = crate::Result<Vec<GetThemesResponse>>>;
|
||||
|
||||
// -- Actions plugins contribute to the UI --
|
||||
|
||||
fn http_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpRequestActionsResponse>>>;
|
||||
fn websocket_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWebsocketRequestActionsResponse>>>;
|
||||
fn grpc_request_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetGrpcRequestActionsResponse>>>;
|
||||
fn workspace_actions(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetWorkspaceActionsResponse>>>;
|
||||
fn folder_actions(&self) -> impl Future<Output = crate::Result<Vec<GetFolderActionsResponse>>>;
|
||||
|
||||
/// Running an action. The request in each of these has already been
|
||||
/// re-read and had its inheritance resolved by the handler; a host must
|
||||
/// pass it through untouched.
|
||||
fn call_http_request_action(
|
||||
&self,
|
||||
req: CallHttpRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_grpc_request_action(
|
||||
&self,
|
||||
req: CallGrpcRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_websocket_request_action(
|
||||
&self,
|
||||
req: CallWebsocketRequestActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_workspace_action(
|
||||
&self,
|
||||
req: CallWorkspaceActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
fn call_folder_action(
|
||||
&self,
|
||||
req: CallFolderActionRequest,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- Authentication --
|
||||
|
||||
fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> impl Future<Output = crate::Result<Vec<GetHttpAuthenticationSummaryResponse>>>;
|
||||
|
||||
/// The form an auth plugin wants to show. `values` arrive already rendered.
|
||||
fn http_authentication_config(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<GetHttpAuthenticationConfigResponse>>;
|
||||
|
||||
fn call_http_authentication_action(
|
||||
&self,
|
||||
auth_name: &str,
|
||||
action_index: i32,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
model_id: &str,
|
||||
) -> impl Future<Output = crate::Result<()>>;
|
||||
|
||||
// -- The importers, and the runtime itself --
|
||||
|
||||
/// Hand arbitrary text to the importer plugins and take what they make of
|
||||
/// it. Used for files, URLs and pasted `curl` commands alike.
|
||||
fn import_data(&self, content: &str) -> impl Future<Output = crate::Result<ImportResponse>>;
|
||||
|
||||
/// Restart every plugin, returning `(plugin, error)` for those that failed.
|
||||
fn reload_plugins(&self, plugins: Vec<Plugin>) -> impl Future<Output = Vec<(String, String)>>;
|
||||
|
||||
/// Re-encrypt the `secure(...)` values in a template.
|
||||
///
|
||||
/// Whole operation rather than its pieces because the encryption is only
|
||||
|
||||
@@ -11,13 +11,18 @@
|
||||
//! host-specific types; the ones that stay behind are the ones only a desktop
|
||||
//! can serve (native windows, the updater, dialogs) or that still lean on it.
|
||||
|
||||
pub mod actions;
|
||||
pub mod auth;
|
||||
pub mod data;
|
||||
pub mod encryption;
|
||||
pub mod error;
|
||||
pub mod host;
|
||||
pub mod models;
|
||||
pub mod plugins;
|
||||
pub mod render;
|
||||
pub mod resolve;
|
||||
pub mod responses;
|
||||
pub mod templates;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use host::{Host, PluginHost};
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Rendering a template against an environment chain.
|
||||
//!
|
||||
//! The variables come from the chain, the functions come from the host's
|
||||
//! template callback. Neither of these knows which host it is running under —
|
||||
//! that is the whole point of taking the callback as a parameter.
|
||||
|
||||
use serde_json::Value;
|
||||
use yaak_models::models::Environment;
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_template<T: TemplateCallback>(
|
||||
template: &str,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
parse_and_render(template, vars, cb, opt).await
|
||||
}
|
||||
|
||||
pub async fn render_json_value<T: TemplateCallback>(
|
||||
value: Value,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<Value> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
render_json_value_raw(value, vars, cb, opt).await
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Filling in what a request inherits from its folders and workspace.
|
||||
//!
|
||||
//! A request stored in the database records only what is set *on it*;
|
||||
//! authentication and headers can come from any ancestor. Anything that acts on
|
||||
//! a request as the user sees it — sending it, handing it to a plugin — has to
|
||||
//! resolve that chain first, which is why this is shared rather than living
|
||||
//! next to any one caller.
|
||||
|
||||
use crate::error::Result;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::models::{GrpcRequest, HttpRequest, WebsocketRequest};
|
||||
|
||||
/// The request with inherited auth and headers filled in, plus the id of the
|
||||
/// model the authentication was inherited *from* — plugins key their token
|
||||
/// caches on it, so it must be the ancestor's id and not the request's.
|
||||
pub fn resolve_http_request(db: &ClientDb, request: &HttpRequest) -> Result<(HttpRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_http_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_http_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_grpc_request(db: &ClientDb, request: &GrpcRequest) -> Result<(GrpcRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_grpc_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.metadata = db.resolve_metadata_for_grpc_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
|
||||
pub fn resolve_websocket_request(
|
||||
db: &ClientDb,
|
||||
request: &WebsocketRequest,
|
||||
) -> Result<(WebsocketRequest, String)> {
|
||||
let mut new_request = request.clone();
|
||||
|
||||
let (authentication_type, authentication, authentication_context_id) =
|
||||
db.resolve_auth_for_websocket_request(request)?;
|
||||
new_request.authentication_type = authentication_type;
|
||||
new_request.authentication = authentication;
|
||||
|
||||
new_request.headers = db.resolve_headers_for_websocket_request(request)?;
|
||||
|
||||
Ok((new_request, authentication_context_id))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Templates, the functions plugins put in them, and themes.
|
||||
//!
|
||||
//! Everything here needs the plugin runtime, but only for the one thing it
|
||||
//! uniquely provides: running a template function. Resolving the environment
|
||||
//! chain and deciding what a render should do about errors are ordinary work
|
||||
//! and stay here, where every host gets them the same.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::PluginHost;
|
||||
use crate::render::render_template;
|
||||
use yaak_plugins::events::{
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::{RenderErrorBehavior, RenderOptions, transform_args};
|
||||
|
||||
pub async fn cmd_render_template<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdRenderTemplateReq,
|
||||
) -> Result<String> {
|
||||
let environment_chain =
|
||||
host.db().resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())?;
|
||||
let cb = host.template_callback(req.purpose.unwrap_or(RenderPurpose::Preview));
|
||||
let options = RenderOptions {
|
||||
// A preview that throws would show the user an error where they expect
|
||||
// to see the value so far, so callers rendering *into the UI* ask for
|
||||
// empties instead.
|
||||
error_behavior: match req.ignore_error {
|
||||
Some(true) => RenderErrorBehavior::ReturnEmpty,
|
||||
_ => RenderErrorBehavior::Throw,
|
||||
},
|
||||
};
|
||||
Ok(render_template(&req.template, environment_chain, &cb, &options).await?)
|
||||
}
|
||||
|
||||
/// Render only the *arguments* of a template's function calls, leaving the
|
||||
/// calls themselves intact. This is what turns a parsed template back into
|
||||
/// something displayable without evaluating it.
|
||||
pub async fn cmd_template_tokens_to_string<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateTokensToStringReq,
|
||||
) -> Result<String> {
|
||||
let cb = host.template_callback(RenderPurpose::Preview);
|
||||
Ok(transform_args(req.tokens, &cb)?.to_string())
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_summaries<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdTemplateFunctionSummariesReq,
|
||||
) -> Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
host.template_function_summaries().await
|
||||
}
|
||||
|
||||
pub async fn cmd_template_function_config<H: PluginHost>(
|
||||
host: H,
|
||||
req: CmdTemplateFunctionConfigReq,
|
||||
) -> Result<GetTemplateFunctionConfigResponse> {
|
||||
host.template_function_config(&req.function_name, req.values, req.model.id()).await
|
||||
}
|
||||
|
||||
pub async fn cmd_get_themes<H: PluginHost>(
|
||||
host: H,
|
||||
_req: CmdGetThemesReq,
|
||||
) -> Result<Vec<GetThemesResponse>> {
|
||||
host.themes().await
|
||||
}
|
||||
@@ -8,25 +8,39 @@
|
||||
//! `PluginHost` too, without one, which is only possible because that trait
|
||||
//! names operations rather than handing back a manager.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tempfile::TempDir;
|
||||
use yaak_commands::auth::cmd_get_http_authentication_config;
|
||||
use yaak_commands::models::{
|
||||
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
|
||||
models_workspace_models,
|
||||
};
|
||||
use yaak_commands::templates::cmd_render_template;
|
||||
use yaak_commands::{Host, PluginHost};
|
||||
use yaak_core::WorkspaceContext;
|
||||
use yaak_crypto::manager::EncryptionManager;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::models::{AnyModel, Plugin, Workspace};
|
||||
use yaak_models::models::{AnyModel, Environment, EnvironmentVariable, Plugin, Workspace};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_plugins::events::{
|
||||
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
|
||||
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, GetFolderActionsResponse,
|
||||
GetGrpcRequestActionsResponse, GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse, GetHttpRequestActionsResponse,
|
||||
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
|
||||
GetWebsocketRequestActionsResponse, GetWorkspaceActionsResponse, ImportResponse, JsonPrimitive,
|
||||
RenderPurpose,
|
||||
};
|
||||
use yaak_plugins::plugin_meta::PluginMetadata;
|
||||
use yaak_rpc_schema::{
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, ModelsDeleteReq, ModelsUpsertReq,
|
||||
ModelsWorkspaceModelsReq,
|
||||
CmdDefaultHeadersReq, CmdGetWorkspaceMetaReq, CmdRenderTemplateReq, ModelsDeleteReq,
|
||||
ModelsUpsertReq, ModelsWorkspaceModelsReq,
|
||||
};
|
||||
use yaak_templates::TemplateCallback;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestHost {
|
||||
@@ -155,6 +169,9 @@ async fn host_free_handlers_need_no_state() {
|
||||
#[derive(Clone)]
|
||||
struct SingleThreadedHost {
|
||||
inner: Rc<Inner>,
|
||||
/// The values the last auth-config call arrived with, so a test can check
|
||||
/// they were rendered before the host ever saw them.
|
||||
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
|
||||
}
|
||||
|
||||
impl Host for SingleThreadedHost {
|
||||
@@ -183,6 +200,32 @@ impl Host for SingleThreadedHost {
|
||||
}
|
||||
}
|
||||
|
||||
/// A template callback with no plugins behind it: variables still resolve,
|
||||
/// function calls have nothing to run them. A browser host would put a Worker
|
||||
/// round-trip where this returns an error.
|
||||
struct NoTemplateFunctions;
|
||||
|
||||
impl TemplateCallback for NoTemplateFunctions {
|
||||
async fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Err(yaak_templates::error::Error::RenderError(format!(
|
||||
"no plugin runtime to run {fn_name}()"
|
||||
)))
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
&self,
|
||||
_fn_name: &str,
|
||||
_arg_name: &str,
|
||||
arg_value: &str,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Ok(arg_value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Answering plugin questions with no plugin runtime behind them. A browser
|
||||
/// host would put a `postMessage` round-trip to its Worker where these return
|
||||
/// constants; the shape of the trait is what makes either possible.
|
||||
@@ -204,12 +247,136 @@ impl PluginHost for SingleThreadedHost {
|
||||
async fn encrypt_secure_template(&self, _template: &str) -> yaak_commands::Result<String> {
|
||||
Err(yaak_commands::Error::Generic("no plugin runtime on this host".into()))
|
||||
}
|
||||
|
||||
fn template_callback(&self, _purpose: RenderPurpose) -> impl TemplateCallback {
|
||||
NoTemplateFunctions
|
||||
}
|
||||
|
||||
async fn template_function_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetTemplateFunctionSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn template_function_config(
|
||||
&self,
|
||||
function_name: &str,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
|
||||
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
|
||||
}
|
||||
|
||||
async fn themes(&self) -> yaak_commands::Result<Vec<GetThemesResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
// No plugins, so nothing contributes actions and nothing can run one.
|
||||
|
||||
async fn http_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn websocket_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetWebsocketRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn grpc_request_actions(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetGrpcRequestActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn workspace_actions(&self) -> yaak_commands::Result<Vec<GetWorkspaceActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn folder_actions(&self) -> yaak_commands::Result<Vec<GetFolderActionsResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn call_http_request_action(
|
||||
&self,
|
||||
_req: CallHttpRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_grpc_request_action(
|
||||
&self,
|
||||
_req: CallGrpcRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_websocket_request_action(
|
||||
&self,
|
||||
_req: CallWebsocketRequestActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_workspace_action(
|
||||
&self,
|
||||
_req: CallWorkspaceActionRequest,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_folder_action(&self, _req: CallFolderActionRequest) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn http_authentication_summaries(
|
||||
&self,
|
||||
) -> yaak_commands::Result<Vec<GetHttpAuthenticationSummaryResponse>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn http_authentication_config(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<GetHttpAuthenticationConfigResponse> {
|
||||
*self.auth_values.borrow_mut() = Some(values);
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn call_http_authentication_action(
|
||||
&self,
|
||||
_auth_name: &str,
|
||||
_action_index: i32,
|
||||
_values: HashMap<String, JsonPrimitive>,
|
||||
_model_id: &str,
|
||||
) -> yaak_commands::Result<()> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn import_data(&self, _content: &str) -> yaak_commands::Result<ImportResponse> {
|
||||
Err(no_plugins())
|
||||
}
|
||||
|
||||
async fn reload_plugins(&self, _plugins: Vec<Plugin>) -> Vec<(String, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn no_plugins() -> yaak_commands::Error {
|
||||
yaak_commands::Error::Generic("no plugin runtime on this host".into())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost { inner: Rc::new(Arc::into_inner(inner).expect("sole owner")) };
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
|
||||
let id = models_upsert(host.clone(), ModelsUpsertReq { model: AnyModel::Workspace(workspace) })
|
||||
@@ -227,6 +394,42 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
.expect("workspace models");
|
||||
assert!(json.contains(&id), "the workspace should be in its own bootstrap payload");
|
||||
|
||||
// Rendering, on a host whose template callback has no plugins behind it.
|
||||
// Resolving the environment chain is a database read and the render is
|
||||
// shared code; only the callback came from the host. Rendering a real
|
||||
// variable is what proves the chain was resolved rather than skipped.
|
||||
let environment = host
|
||||
.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: id.clone(),
|
||||
name: "Test env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "greeting".to_string(),
|
||||
value: "hello".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("seed environment");
|
||||
|
||||
let rendered = cmd_render_template(
|
||||
host.clone(),
|
||||
CmdRenderTemplateReq {
|
||||
template: "${[ greeting ]} world".to_string(),
|
||||
workspace_id: id.clone(),
|
||||
environment_id: Some(environment.id.clone()),
|
||||
purpose: None,
|
||||
ignore_error: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("render");
|
||||
assert_eq!(rendered, "hello world", "the environment chain should have been resolved");
|
||||
|
||||
// The delete path too, since it is the one that used to reach for a
|
||||
// blocking thread this host does not have.
|
||||
let workspace = host.db().get_workspace(&id).expect("get workspace");
|
||||
@@ -235,3 +438,64 @@ async fn a_single_threaded_host_can_implement_the_trait() {
|
||||
.expect("delete");
|
||||
assert_eq!(deleted, id);
|
||||
}
|
||||
|
||||
/// Auth form values may contain templates, and a plugin must never see one
|
||||
/// unrendered. The rendering happens in the shared handler, so this checks the
|
||||
/// host received a resolved value rather than `${[ ... ]}`.
|
||||
#[tokio::test]
|
||||
async fn auth_values_are_rendered_before_the_host_sees_them() {
|
||||
let TestHost { inner } = TestHost::new();
|
||||
let host = SingleThreadedHost {
|
||||
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
|
||||
auth_values: Rc::new(RefCell::new(None)),
|
||||
};
|
||||
|
||||
let workspace = host
|
||||
.db()
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Auth".to_string(), ..Default::default() },
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("workspace");
|
||||
host.db()
|
||||
.upsert_environment(
|
||||
&Environment {
|
||||
workspace_id: workspace.id.clone(),
|
||||
name: "Env".to_string(),
|
||||
variables: vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "token".to_string(),
|
||||
value: "s3cret".to_string(),
|
||||
id: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&host.update_source(),
|
||||
)
|
||||
.expect("environment");
|
||||
let environment =
|
||||
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
|
||||
|
||||
let mut values = HashMap::new();
|
||||
values.insert("password".to_string(), JsonPrimitive::String("${[ token ]}".to_string()));
|
||||
|
||||
// The host refuses the call itself — it has no plugins — but only after the
|
||||
// handler has rendered and handed over the values, which is what matters.
|
||||
let _ = cmd_get_http_authentication_config(
|
||||
host.clone(),
|
||||
yaak_rpc_schema::CmdGetHttpAuthenticationConfigReq {
|
||||
auth_name: "basic".to_string(),
|
||||
values,
|
||||
model: AnyModel::Workspace(workspace),
|
||||
environment_id: Some(environment.id),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = host.auth_values.borrow().clone().expect("the host should have been called");
|
||||
assert!(
|
||||
matches!(seen.get("password"), Some(JsonPrimitive::String(v)) if v == "s3cret"),
|
||||
"the template should have been rendered before reaching the host, got {:?}",
|
||||
seen.get("password"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ hyper-util = { version = "0.1.17", default-features = false, features = ["client
|
||||
log = { workspace = true }
|
||||
mime_guess = "2.0.5"
|
||||
native-tls = { version = "0.2", features = ["alpn"] }
|
||||
regex = "1.11.1"
|
||||
reqwest = { workspace = true, features = [
|
||||
"rustls-tls-manual-roots-no-provider",
|
||||
"native-tls",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dns::LocalhostResolver;
|
||||
use crate::dns::{AddressFilter, LocalhostResolver};
|
||||
use crate::error::Result;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
@@ -103,13 +103,18 @@ pub struct HttpConnectionOptions {
|
||||
pub proxy: HttpConnectionProxySetting,
|
||||
pub client_certificate: Option<ClientCertificateConfig>,
|
||||
pub dns_overrides: Vec<DnsOverride>,
|
||||
/// Refuse connections to addresses a hostname resolves to. `None` means
|
||||
/// every resolved address is connectable, which is what the desktop wants:
|
||||
/// a user sending to their own machine or their own network is the point.
|
||||
/// A hosted sender is the caller that supplies one.
|
||||
pub address_filter: Option<AddressFilter>,
|
||||
}
|
||||
|
||||
impl HttpConnectionOptions {
|
||||
/// Build a reqwest Client and return it along with the DNS resolver.
|
||||
/// The resolver is returned separately so it can be configured per-request
|
||||
/// to emit DNS timing events to the appropriate channel.
|
||||
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
let mut client = client_builder()
|
||||
.connection_verbose(true)
|
||||
.redirect(redirect::Policy::none())
|
||||
@@ -135,7 +140,10 @@ impl HttpConnectionOptions {
|
||||
}
|
||||
|
||||
// Configure DNS resolver - keep a reference to configure per-request
|
||||
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
|
||||
let resolver = LocalhostResolver::with_address_filter(
|
||||
self.dns_overrides.clone(),
|
||||
self.address_filter.clone(),
|
||||
);
|
||||
client = client.dns_resolver(resolver.clone());
|
||||
|
||||
// Configure proxy
|
||||
|
||||
@@ -20,15 +20,32 @@ pub struct ResolvedOverride {
|
||||
pub ipv6: Vec<Ipv6Addr>,
|
||||
}
|
||||
|
||||
/// A veto on the addresses a hostname resolves to, consulted after resolution
|
||||
/// and before any connection is made. Returning `Err` refuses the whole lookup
|
||||
/// with that message; a hostname is never partially allowed.
|
||||
///
|
||||
/// A hosted sender uses this to refuse private and metadata ranges no matter
|
||||
/// what name they hide behind. Checking here rather than on the URL is what
|
||||
/// catches a public hostname that resolves to an internal address.
|
||||
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct LocalhostResolver {
|
||||
fallback: HyperGaiResolver,
|
||||
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
|
||||
overrides: Arc<HashMap<String, ResolvedOverride>>,
|
||||
address_filter: Option<AddressFilter>,
|
||||
}
|
||||
|
||||
impl LocalhostResolver {
|
||||
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
|
||||
Self::with_address_filter(dns_overrides, None)
|
||||
}
|
||||
|
||||
pub fn with_address_filter(
|
||||
dns_overrides: Vec<DnsOverride>,
|
||||
address_filter: Option<AddressFilter>,
|
||||
) -> Arc<Self> {
|
||||
let resolver = HyperGaiResolver::new();
|
||||
|
||||
// Pre-parse DNS overrides into a lookup map
|
||||
@@ -55,9 +72,25 @@ impl LocalhostResolver {
|
||||
fallback: resolver,
|
||||
event_tx: Arc::new(RwLock::new(None)),
|
||||
overrides: Arc::new(overrides),
|
||||
address_filter,
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply the address filter, if any, to a resolved address list.
|
||||
fn filter_addrs(
|
||||
filter: &Option<AddressFilter>,
|
||||
addrs: &[SocketAddr],
|
||||
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(filter) = filter {
|
||||
for addr in addrs {
|
||||
if let Err(reason) = filter(addr.ip()) {
|
||||
return Err(Box::new(std::io::Error::other(reason)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set the event sender for the current request.
|
||||
/// This should be called before each request to direct DNS events
|
||||
/// to the appropriate channel.
|
||||
@@ -72,6 +105,7 @@ impl Resolve for LocalhostResolver {
|
||||
let host = name.as_str().to_lowercase();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let overrides = self.overrides.clone();
|
||||
let address_filter = self.address_filter.clone();
|
||||
|
||||
info!("DNS resolve called for: {}", host);
|
||||
|
||||
@@ -94,6 +128,8 @@ impl Resolve for LocalhostResolver {
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
Self::filter_addrs(&address_filter, &addrs)?;
|
||||
|
||||
// Emit DNS event for override
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
@@ -125,6 +161,8 @@ impl Resolve for LocalhostResolver {
|
||||
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
return Box::pin(async move {
|
||||
Self::filter_addrs(&address_filter, &addrs)?;
|
||||
|
||||
// Emit DNS event for localhost resolution
|
||||
let guard = event_tx.read().await;
|
||||
if let Some(tx) = guard.as_ref() {
|
||||
@@ -161,6 +199,7 @@ impl Resolve for LocalhostResolver {
|
||||
Ok(addrs) => {
|
||||
// Collect addresses for event emission
|
||||
let addr_vec: Vec<SocketAddr> = addrs.collect();
|
||||
Self::filter_addrs(&address_filter, &addr_vec)?;
|
||||
let addresses: Vec<String> =
|
||||
addr_vec.iter().map(|a| a.ip().to_string()).collect();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ pub mod decompress;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod manager;
|
||||
pub mod path_placeholders;
|
||||
mod proto;
|
||||
pub mod sender;
|
||||
pub mod tee_reader;
|
||||
pub mod transaction;
|
||||
pub mod types;
|
||||
|
||||
// Moved to yaak-models so the browser's wasm host can render requests with the
|
||||
// same code; re-exported here so existing callers keep their path.
|
||||
pub use yaak_models::path_placeholders;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Lifecycle hooks shared by every host (desktop, browser, CLI). The hooks say
|
||||
//! what happens at each moment; the host decides when and on which thread.
|
||||
//!
|
||||
//! Builds for wasm32, so it can depend on `yaak-models` but not on the send
|
||||
//! engine or plugin runtime.
|
||||
|
||||
use log::info;
|
||||
use std::path::PathBuf;
|
||||
use yaak_models::blob_manager::BlobManager;
|
||||
use yaak_models::client_db::ClientDb;
|
||||
use yaak_models::error::Result;
|
||||
|
||||
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// Has the database for the life of the app (desktop, browser worker)
|
||||
Owner,
|
||||
/// Short-lived, and an owner may be using the database right now (CLI).
|
||||
/// Must not touch anything in flight.
|
||||
Guest,
|
||||
}
|
||||
|
||||
/// Paths are `None` on hosts without a filesystem (the browser).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Host {
|
||||
pub role: Role,
|
||||
pub responses_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn owner() -> Self {
|
||||
Self { role: Role::Owner, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn guest() -> Self {
|
||||
Self { role: Role::Guest, responses_dir: None }
|
||||
}
|
||||
|
||||
pub fn with_responses_dir(mut self, dir: impl Into<PathBuf>) -> Self {
|
||||
self.responses_dir = Some(dir.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Run once after the database is open, before the host answers anything.
|
||||
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
|
||||
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
|
||||
|
||||
if host.role == Role::Owner {
|
||||
// Anything still in flight was left by the last session
|
||||
db.cancel_pending_http_responses()?;
|
||||
db.cancel_pending_grpc_connections()?;
|
||||
db.cancel_pending_websocket_connections()?;
|
||||
|
||||
// Cascaded deletes never cleaned up response bodies
|
||||
let deleted = match host.responses_dir.as_deref() {
|
||||
Some(dir) => db.delete_orphaned_response_bodies(blobs, dir)?,
|
||||
None => db.delete_orphaned_response_body_blobs(blobs)?,
|
||||
};
|
||||
if deleted > 0 {
|
||||
info!("Deleted {deleted} orphaned response bodies");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use yaak_models::blob_manager::BodyChunk;
|
||||
use yaak_models::init_in_memory;
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseState, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn only_the_owner_closes_what_the_last_session_left_open() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
let source = &UpdateSource::Background;
|
||||
|
||||
let workspace = db
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "Hooks".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.unwrap();
|
||||
let pending = db
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
state: HttpResponseState::Connected,
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
&blob_manager,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Connected));
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
let response = db.get_http_response(&pending.id).unwrap();
|
||||
assert!(matches!(response.state, HttpResponseState::Closed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_without_a_filesystem_still_sweeps_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
{
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
|
||||
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
|
||||
|
||||
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ serde_json = { workspace = true }
|
||||
schemars = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
urlencoding = "2.1.3"
|
||||
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
|
||||
yaak-core = { workspace = true }
|
||||
yaak-templates = { path = "../yaak-templates", default-features = false }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
r2d2 = "0.8.10"
|
||||
|
||||
+16
-1
@@ -225,7 +225,6 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
@@ -305,6 +304,22 @@ export type HttpResponseHeader = { name: string; value: string };
|
||||
|
||||
export type HttpResponseState = "initialized" | "connected" | "closed";
|
||||
|
||||
/**
|
||||
* The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
* crosses from a tab to the Yaak server, and what the server reads.
|
||||
*/
|
||||
export type HttpSendSettings = {
|
||||
validateCertificates: boolean;
|
||||
followRedirects: boolean;
|
||||
/**
|
||||
* Milliseconds. Zero or negative means no timeout.
|
||||
*/
|
||||
timeoutMs: number;
|
||||
sendCookies: boolean;
|
||||
storeCookies: boolean;
|
||||
};
|
||||
|
||||
export type HttpUrlParameter = {
|
||||
enabled?: boolean;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createStore } from "jotai";
|
||||
import { expect, test } from "vitest";
|
||||
import type { HttpResponseEvent } from "../bindings/gen_models";
|
||||
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
|
||||
import { newStoreData } from "./util";
|
||||
|
||||
// The five setting events that every send writes, all within the same millisecond
|
||||
const SETTING_NAMES = [
|
||||
"validate_certificates",
|
||||
"redirects",
|
||||
"timeout",
|
||||
"send_cookies",
|
||||
"store_cookies",
|
||||
];
|
||||
|
||||
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
|
||||
return {
|
||||
model: "http_response_event",
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
workspaceId: "wk_1",
|
||||
responseId: "rs_1",
|
||||
event: { type: "setting", name, value: "true" },
|
||||
};
|
||||
}
|
||||
|
||||
test("events with equal createdAt keep store (DB) insertion order", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
SETTING_NAMES.forEach((name, i) => {
|
||||
data.http_response_event[`hre_${i}`] = settingEvent(
|
||||
`hre_${i}`,
|
||||
name,
|
||||
"2026-08-17T00:00:00.123",
|
||||
);
|
||||
});
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
const names = store.get(httpResponseEventsAtom).map((e) => {
|
||||
return e.event.type === "setting" ? e.event.name : e.event.type;
|
||||
});
|
||||
expect(names).toEqual(SETTING_NAMES);
|
||||
});
|
||||
|
||||
test("events with distinct createdAt sort ascending", () => {
|
||||
const store = createStore();
|
||||
const data = newStoreData();
|
||||
for (const [id, createdAt] of [
|
||||
["hre_b", "2026-08-17T00:00:00.456"],
|
||||
["hre_a", "2026-08-17T00:00:00.123"],
|
||||
["hre_c", "2026-08-17T00:00:00.789"],
|
||||
]) {
|
||||
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
|
||||
}
|
||||
store.set(modelStoreDataAtom, data);
|
||||
|
||||
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
|
||||
});
|
||||
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
|
||||
const modelData = data[modelType] ?? {};
|
||||
return Object.values(modelData).sort(
|
||||
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
|
||||
const n = a[field] > b[field] ? 1 : -1;
|
||||
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
|
||||
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
|
||||
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
|
||||
return order === "desc" ? n * -1 : n;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! Carrying a send's cookie changes back into a jar.
|
||||
//!
|
||||
//! A send starts from a snapshot of the jar and hands back the jar as the
|
||||
//! transaction left it. Writing that whole result over the jar would also
|
||||
//! write over anything the user changed *while* the send was in flight — a
|
||||
//! cookie edited or deleted in the jar view, or set by another send. So the
|
||||
//! send's contribution is taken as a difference (what it added, changed, or
|
||||
//! removed relative to its snapshot) and applied to whatever the jar holds now.
|
||||
|
||||
use crate::models::{Cookie, CookieDomain};
|
||||
|
||||
/// The identity of a cookie in a jar: two cookies with the same name, domain
|
||||
/// and path are the same cookie, whatever their value or attributes.
|
||||
type CookieKey = (String, CookieDomain, String);
|
||||
|
||||
fn key(c: &Cookie) -> CookieKey {
|
||||
(c.name.clone(), c.domain.clone(), c.path.clone())
|
||||
}
|
||||
|
||||
/// Apply the changes between `before` (the snapshot a send started from) and
|
||||
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
|
||||
///
|
||||
/// Cookies the send removed are removed; cookies it added or changed replace
|
||||
/// their counterpart in `current`, or are appended. Cookies the send did not
|
||||
/// touch are left exactly as `current` has them.
|
||||
pub fn apply_cookie_changes(
|
||||
current: Vec<Cookie>,
|
||||
before: &[Cookie],
|
||||
after: &[Cookie],
|
||||
) -> Vec<Cookie> {
|
||||
let removed: Vec<CookieKey> =
|
||||
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
|
||||
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
|
||||
|
||||
let mut result: Vec<Cookie> =
|
||||
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
|
||||
for cookie in changed {
|
||||
match result.iter_mut().find(|c| key(c) == key(cookie)) {
|
||||
Some(existing) => *existing = cookie.clone(),
|
||||
None => result.push(cookie.clone()),
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::CookieExpires;
|
||||
|
||||
fn cookie(name: &str, value: &str) -> Cookie {
|
||||
Cookie {
|
||||
name: name.to_string(),
|
||||
value: value.to_string(),
|
||||
domain: CookieDomain::HostOnly("example.com".to_string()),
|
||||
expires: CookieExpires::SessionEnd,
|
||||
path: "/".to_string(),
|
||||
secure: false,
|
||||
http_only: false,
|
||||
same_site: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
|
||||
let before = vec![cookie("a", "1")];
|
||||
let current = vec![cookie("a", "edited"), cookie("b", "2")];
|
||||
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additions_and_changes_land_without_touching_concurrent_edits() {
|
||||
let before = vec![cookie("a", "1"), cookie("b", "2")];
|
||||
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
|
||||
// Meanwhile the user edited `a` and added `d`.
|
||||
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
|
||||
assert_eq!(
|
||||
apply_cookie_changes(current, &before, &after),
|
||||
vec![
|
||||
cookie("a", "edited"),
|
||||
cookie("b", "3"),
|
||||
cookie("d", "5"),
|
||||
cookie("c", "4")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cookie_the_send_removed_is_removed() {
|
||||
let before = vec![cookie("a", "1"), cookie("b", "2")];
|
||||
let after = vec![cookie("b", "2")];
|
||||
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
|
||||
assert_eq!(
|
||||
apply_cookie_changes(current, &before, &after),
|
||||
vec![cookie("b", "2"), cookie("c", "3")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
|
||||
let before = vec![cookie("a", "1")];
|
||||
let after = vec![cookie("a", "1")]; // untouched by the send
|
||||
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
|
||||
let after = vec![cookie("a", "fresh")]; // the send set it again
|
||||
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@ use yaak_database::SqlitePool;
|
||||
|
||||
pub mod blob_manager;
|
||||
pub mod client_db;
|
||||
pub mod cookies;
|
||||
mod connection_or_tx;
|
||||
pub mod error;
|
||||
pub mod migrate;
|
||||
pub mod models;
|
||||
pub mod models_ops;
|
||||
pub mod path_placeholders;
|
||||
pub mod queries;
|
||||
pub mod query_manager;
|
||||
pub mod render;
|
||||
|
||||
@@ -60,8 +60,22 @@ pub struct ProxySettingAuth {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
impl Default for ClientCertificate {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: String::new(),
|
||||
port: None,
|
||||
crt_file: None,
|
||||
key_file: None,
|
||||
pfx_file: None,
|
||||
passphrase: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct ClientCertificate {
|
||||
pub host: String,
|
||||
@@ -75,13 +89,18 @@ pub struct ClientCertificate {
|
||||
pub pfx_file: Option<String>,
|
||||
#[serde(default)]
|
||||
pub passphrase: Option<String>,
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
impl Default for DnsOverride {
|
||||
fn default() -> Self {
|
||||
Self { hostname: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct DnsOverride {
|
||||
pub hostname: String,
|
||||
@@ -89,7 +108,6 @@ pub struct DnsOverride {
|
||||
pub ipv4: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub ipv6: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
@@ -140,6 +158,70 @@ impl Default for ResolvedHttpRequestSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl ResolvedHttpRequestSettings {
|
||||
/// The `* Setting name=value` lines a send writes at the top of its timeline, sources and
|
||||
/// all. Built here, once, so every host that runs a send — the desktop, the CLI, the browser
|
||||
/// tab handing off to a proxy — records the same lines the same way.
|
||||
pub fn timeline_events(&self) -> Vec<HttpResponseEventData> {
|
||||
fn event<T>(
|
||||
name: &str,
|
||||
value: String,
|
||||
setting: &ResolvedSetting<T>,
|
||||
) -> HttpResponseEventData {
|
||||
HttpResponseEventData::Setting {
|
||||
name: name.to_string(),
|
||||
value,
|
||||
source_model: Some(setting.source_model.clone()),
|
||||
source_id: setting.source_id.clone(),
|
||||
source_name: setting.source_name.clone(),
|
||||
}
|
||||
}
|
||||
let timeout = if self.request_timeout.value > 0 {
|
||||
format!("{:?}", std::time::Duration::from_millis(self.request_timeout.value as u64))
|
||||
} else {
|
||||
"Infinity".to_string()
|
||||
};
|
||||
vec![
|
||||
event(
|
||||
"validate_certificates",
|
||||
self.validate_certificates.value.to_string(),
|
||||
&self.validate_certificates,
|
||||
),
|
||||
event("redirects", self.follow_redirects.value.to_string(), &self.follow_redirects),
|
||||
event("timeout", timeout, &self.request_timeout),
|
||||
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
|
||||
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/// The resolved send settings, values only: what an executor has to obey, with the sources
|
||||
/// (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
|
||||
/// crosses from a tab to the Yaak server, and what the server reads.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpSendSettings {
|
||||
pub validate_certificates: bool,
|
||||
pub follow_redirects: bool,
|
||||
/// Milliseconds. Zero or negative means no timeout.
|
||||
pub timeout_ms: i32,
|
||||
pub send_cookies: bool,
|
||||
pub store_cookies: bool,
|
||||
}
|
||||
|
||||
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
|
||||
fn from(s: &ResolvedHttpRequestSettings) -> Self {
|
||||
Self {
|
||||
validate_certificates: s.validate_certificates.value,
|
||||
follow_redirects: s.follow_redirects.value,
|
||||
timeout_ms: s.request_timeout.value,
|
||||
send_cookies: s.send_cookies.value,
|
||||
store_cookies: s.store_cookies.value,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
@@ -147,7 +229,6 @@ pub struct InheritedBoolSetting {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub value: bool,
|
||||
}
|
||||
|
||||
@@ -383,7 +464,31 @@ impl UpsertModelInfo for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "workspace".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
name: String::new(),
|
||||
encryption_key_challenge: None,
|
||||
setting_validate_certificates: true,
|
||||
setting_follow_redirects: true,
|
||||
setting_request_timeout: 0,
|
||||
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_dns_overrides: Vec::new(),
|
||||
setting_send_cookies: true,
|
||||
setting_store_cookies: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "workspaces")]
|
||||
@@ -403,18 +508,13 @@ pub struct Workspace {
|
||||
pub encryption_key_challenge: Option<String>,
|
||||
|
||||
// Settings
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_validate_certificates: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_follow_redirects: bool,
|
||||
pub setting_request_timeout: i32,
|
||||
#[serde(default = "default_request_message_size")]
|
||||
pub setting_request_message_size: i32,
|
||||
#[serde(default)]
|
||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_send_cookies: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub setting_store_cookies: bool,
|
||||
}
|
||||
|
||||
@@ -920,11 +1020,16 @@ impl UpsertModelInfo for Environment {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for EnvironmentVariable {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct EnvironmentVariable {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
@@ -949,7 +1054,35 @@ pub struct ParentHeaders {
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for Folder {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "folder".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||
setting_request_timeout: InheritedIntSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "folders")]
|
||||
@@ -974,7 +1107,6 @@ pub struct Folder {
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
pub setting_follow_redirects: InheritedBoolSetting,
|
||||
pub setting_request_timeout: InheritedIntSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -1088,11 +1220,16 @@ impl UpsertModelInfo for Folder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpRequestHeader {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpRequestHeader {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
pub name: String,
|
||||
@@ -1101,11 +1238,16 @@ pub struct HttpRequestHeader {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpUrlParameter {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true, name: String::new(), value: String::new(), id: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct HttpUrlParameter {
|
||||
#[serde(default = "default_true")]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
|
||||
@@ -1116,7 +1258,36 @@ pub struct HttpUrlParameter {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for HttpRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "http_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
body: BTreeMap::new(),
|
||||
body_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
method: "GET".to_string(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
url_parameters: Vec::new(),
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_follow_redirects: InheritedBoolSetting::default(),
|
||||
setting_request_timeout: InheritedIntSetting::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "http_requests")]
|
||||
@@ -1137,7 +1308,6 @@ pub struct HttpRequest {
|
||||
pub body_type: Option<String>,
|
||||
pub description: String,
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
#[serde(default = "default_http_method")]
|
||||
pub method: String,
|
||||
pub name: String,
|
||||
pub sort_priority: f64,
|
||||
@@ -1393,7 +1563,36 @@ impl Default for WebsocketMessageType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for WebsocketRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "websocket_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication: BTreeMap::new(),
|
||||
authentication_type: None,
|
||||
description: String::new(),
|
||||
headers: Vec::new(),
|
||||
message: String::new(),
|
||||
name: String::new(),
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
url_parameters: Vec::new(),
|
||||
setting_send_cookies: InheritedBoolSetting::default(),
|
||||
setting_store_cookies: InheritedBoolSetting::default(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "websocket_requests")]
|
||||
@@ -1420,7 +1619,6 @@ pub struct WebsocketRequest {
|
||||
pub setting_send_cookies: InheritedBoolSetting,
|
||||
pub setting_store_cookies: InheritedBoolSetting,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -1677,6 +1875,13 @@ pub struct HttpResponse {
|
||||
pub workspace_id: String,
|
||||
pub request_id: String,
|
||||
|
||||
/// Where the engine put the body, when it puts it in a file.
|
||||
///
|
||||
/// Not exported to TypeScript: a path is only meaningful to a host that
|
||||
/// has the filesystem it names, and bodies are moving off it. Read a body
|
||||
/// by response id instead — the frontend through
|
||||
/// `cmd_http_response_body_path`, plugins through `ctx.httpResponse.body`.
|
||||
#[ts(skip)]
|
||||
pub body_path: Option<String>,
|
||||
pub content_length: Option<i32>,
|
||||
pub content_length_compressed: Option<i32>,
|
||||
@@ -2046,7 +2251,35 @@ impl UpsertModelInfo for GraphQlIntrospection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
impl Default for GrpcRequest {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
model: "grpc_request".to_string(),
|
||||
id: String::new(),
|
||||
created_at: NaiveDateTime::default(),
|
||||
updated_at: NaiveDateTime::default(),
|
||||
workspace_id: String::new(),
|
||||
folder_id: None,
|
||||
authentication_type: None,
|
||||
authentication: BTreeMap::new(),
|
||||
description: String::new(),
|
||||
message: String::new(),
|
||||
metadata: Vec::new(),
|
||||
method: None,
|
||||
name: String::new(),
|
||||
service: None,
|
||||
sort_priority: 0.0,
|
||||
url: String::new(),
|
||||
setting_validate_certificates: InheritedBoolSetting::default(),
|
||||
setting_request_message_size: InheritedIntSetting {
|
||||
enabled: false,
|
||||
value: DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "grpc_requests")]
|
||||
@@ -2072,7 +2305,6 @@ pub struct GrpcRequest {
|
||||
/// Server URL (http for plaintext or https for secure)
|
||||
pub url: String,
|
||||
pub setting_validate_certificates: InheritedBoolSetting,
|
||||
#[serde(default = "default_request_message_size_setting")]
|
||||
pub setting_request_message_size: InheritedIntSetting,
|
||||
}
|
||||
|
||||
@@ -2723,22 +2955,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_request_message_size() -> i32 {
|
||||
DEFAULT_REQUEST_MESSAGE_SIZE
|
||||
}
|
||||
|
||||
/// Only used as a `from_row` fallback for an unparseable settings column. The
|
||||
/// value a *new* model gets comes from that model's `Default` impl.
|
||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
||||
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
|
||||
}
|
||||
|
||||
fn default_http_method() -> String {
|
||||
"GET".to_string()
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! define_any_model {
|
||||
($($type:ident),* $(,)?) => {
|
||||
@@ -2882,3 +3104,65 @@ impl AnyModel {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Every model below carries `#[serde(default)]` at the container level, so a
|
||||
/// missing key is filled from `Default::default()`, which makes each `Default`
|
||||
/// impl the single definition of that model's defaults.
|
||||
///
|
||||
/// Deserializing `{}` therefore equals `Default::default()` by construction
|
||||
/// today. What this catches is the two ways that can come apart again, both of
|
||||
/// which have already bitten us:
|
||||
///
|
||||
/// 1. A field-level `#[serde(default = "...")]` (or bare `#[serde(default)]`)
|
||||
/// added back on a field whose `Default` says something else. That is exactly
|
||||
/// the shape of the bug this replaced: `setting_send_cookies` deserialized as
|
||||
/// true but a derived `Default` produced false, so the bootstrapped workspace
|
||||
/// silently sent no cookies.
|
||||
/// 2. The container-level `#[serde(default)]` being dropped, which turns every
|
||||
/// missing key into a deserialization error instead.
|
||||
macro_rules! assert_default_matches_serde {
|
||||
($($t:ty),+ $(,)?) => {
|
||||
$(
|
||||
assert_eq!(
|
||||
serde_json::from_str::<$t>("{}").expect(concat!(
|
||||
stringify!($t),
|
||||
" must deserialize from an empty object"
|
||||
)),
|
||||
<$t>::default(),
|
||||
concat!(stringify!($t), ": Default::default() disagrees with its serde defaults"),
|
||||
);
|
||||
)+
|
||||
};
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_serde_defaults() {
|
||||
assert_default_matches_serde!(
|
||||
Workspace,
|
||||
HttpRequest,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
WebsocketRequest,
|
||||
HttpRequestHeader,
|
||||
HttpUrlParameter,
|
||||
EnvironmentVariable,
|
||||
DnsOverride,
|
||||
ClientCertificate,
|
||||
InheritedBoolSetting,
|
||||
InheritedIntSetting,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_carry_their_model_name() {
|
||||
assert_eq!(Workspace::default().model, "workspace");
|
||||
assert_eq!(HttpRequest::default().model, "http_request");
|
||||
assert_eq!(Folder::default().model, "folder");
|
||||
assert_eq!(GrpcRequest::default().model, "grpc_request");
|
||||
assert_eq!(WebsocketRequest::default().model, "websocket_request");
|
||||
}
|
||||
}
|
||||
|
||||
+54
-16
@@ -1,4 +1,4 @@
|
||||
use yaak_models::models::HttpUrlParameter;
|
||||
use crate::models::HttpUrlParameter;
|
||||
|
||||
pub fn apply_path_placeholders(
|
||||
url: &str,
|
||||
@@ -34,27 +34,41 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
|
||||
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
|
||||
// placeholder while leaving `:increment-importance` as literal text.
|
||||
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
|
||||
let result = re
|
||||
.replace_all(url, |cap: ®ex::Captures| {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
cap[1].to_string(),
|
||||
urlencoding::encode(p.value.as_str()),
|
||||
cap[2].to_string()
|
||||
)
|
||||
})
|
||||
.into_owned();
|
||||
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
|
||||
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
|
||||
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
|
||||
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
|
||||
//
|
||||
// A plain scan rather than a regex: the name is matched literally, so a name containing
|
||||
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
|
||||
let name = p.name.as_str();
|
||||
let value = urlencoding::encode(p.value.as_str());
|
||||
let mut result = String::with_capacity(url.len());
|
||||
let mut rest = url;
|
||||
while let Some(slash) = rest.find('/') {
|
||||
let after_slash = &rest[slash + 1..];
|
||||
let is_placeholder = after_slash.starts_with(name)
|
||||
&& after_slash[name.len()..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
|
||||
if is_placeholder {
|
||||
result.push_str(&rest[..=slash]);
|
||||
result.push_str(&value);
|
||||
rest = &after_slash[name.len()..];
|
||||
} else {
|
||||
result.push_str(&rest[..=slash]);
|
||||
rest = after_slash;
|
||||
}
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placeholder_tests {
|
||||
use crate::models::{HttpRequest, HttpUrlParameter};
|
||||
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
|
||||
use yaak_models::models::{HttpRequest, HttpUrlParameter};
|
||||
|
||||
#[test]
|
||||
fn placeholder_middle() {
|
||||
@@ -98,6 +112,30 @@ mod placeholder_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_name_is_matched_literally() {
|
||||
// `.` in a name is a dot, not "any character".
|
||||
let p = HttpUrlParameter {
|
||||
name: ":id.v2".into(),
|
||||
value: "xxx".into(),
|
||||
enabled: true,
|
||||
id: None,
|
||||
};
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
|
||||
"https://example.com/xxx/:idXv2",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_repeated() {
|
||||
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
|
||||
assert_eq!(
|
||||
replace_path_placeholder(&p, "https://example.com/:id/:id"),
|
||||
"https://example.com/7/7",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_missing() {
|
||||
let p = HttpUrlParameter {
|
||||
@@ -45,6 +45,31 @@ impl<'a> ClientDb<'a> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Delete blob-stored response bodies whose owning HTTP response row no
|
||||
/// longer exists. Blob ids are keyed by the response that owns them —
|
||||
/// "{response_id}" for a response body, "{response_id}.request" for the
|
||||
/// request that produced it — so ownership is the id's first segment.
|
||||
///
|
||||
/// The blob half of [`Self::delete_orphaned_response_bodies`], on its own
|
||||
/// for hosts with no filesystem to hold body files. See `crate::hooks`.
|
||||
///
|
||||
/// Returns the number of orphaned bodies deleted.
|
||||
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
|
||||
let blob_ctx = blobs.connect();
|
||||
for body_id in blob_ctx.list_body_ids()? {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Delete response body data (blob chunks and body files) whose owning HTTP
|
||||
/// response row no longer exists. Cascaded deletes (request, folder,
|
||||
/// workspace) historically never cleaned the blob DB or the responses
|
||||
@@ -59,18 +84,7 @@ impl<'a> ClientDb<'a> {
|
||||
blobs: &BlobManager,
|
||||
responses_dir: &std::path::Path,
|
||||
) -> Result<usize> {
|
||||
let mut deleted = 0;
|
||||
|
||||
// Blob chunks are keyed "{response_id}.request"
|
||||
let blob_ctx = blobs.connect();
|
||||
for body_id in blob_ctx.list_body_ids()? {
|
||||
let response_id = body_id.split('.').next().unwrap_or_default();
|
||||
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
|
||||
continue;
|
||||
}
|
||||
blob_ctx.delete_chunks(&body_id)?;
|
||||
deleted += 1;
|
||||
}
|
||||
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
|
||||
|
||||
// Body files are stored as {responses_dir}/{response_id}
|
||||
if let Ok(entries) = fs::read_dir(responses_dir) {
|
||||
@@ -172,19 +186,20 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blob_manager::BodyChunk;
|
||||
use crate::blob_manager::{BlobManager, BodyChunk};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
fn deletes_orphaned_response_bodies() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
/// A workspace, a request, and one response that still exists.
|
||||
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
|
||||
let source = &UpdateSource::Background;
|
||||
let workspace = db
|
||||
.upsert_workspace(&Workspace { name: "GC Test".to_string(), ..Default::default() }, source)
|
||||
.upsert_workspace(
|
||||
&Workspace { name: "GC Test".to_string(), ..Default::default() },
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert workspace");
|
||||
let request = db
|
||||
.upsert_http_request(
|
||||
@@ -192,19 +207,57 @@ mod tests {
|
||||
source,
|
||||
)
|
||||
.expect("Failed to upsert request");
|
||||
db.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
blob_manager,
|
||||
)
|
||||
.expect("Failed to upsert response")
|
||||
}
|
||||
|
||||
let live = db
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
request_id: request.id.clone(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
&blob_manager,
|
||||
)
|
||||
.expect("Failed to upsert response");
|
||||
/// What a browser host runs: no filesystem, so bodies exist only as blob
|
||||
/// chunks, under both id shapes the blob DB uses.
|
||||
#[test]
|
||||
fn deletes_orphaned_response_body_blobs() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live_request_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
// needs to take it
|
||||
let blob_ctx = blob_manager.connect();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
|
||||
blob_ctx
|
||||
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
|
||||
.unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
|
||||
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
|
||||
}
|
||||
|
||||
let deleted = db
|
||||
.delete_orphaned_response_body_blobs(&blob_manager)
|
||||
.expect("Failed to GC response body blobs");
|
||||
assert_eq!(deleted, 2);
|
||||
|
||||
let blob_ctx = blob_manager.connect();
|
||||
assert!(blob_ctx.body_exists(&live.id).unwrap());
|
||||
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
|
||||
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
|
||||
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletes_orphaned_response_bodies() {
|
||||
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let live = seed_live_response(&db, &blob_manager);
|
||||
let live_body_id = format!("{}.request", live.id);
|
||||
{
|
||||
// Scope the connection: the in-memory pool only has one, and the GC
|
||||
|
||||
@@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
if workspaces.is_empty() {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&Workspace {
|
||||
name: "Yaak".to_string(),
|
||||
setting_follow_redirects: true,
|
||||
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
|
||||
setting_validate_certificates: true,
|
||||
..Default::default()
|
||||
},
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?)
|
||||
}
|
||||
@@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn default_headers() -> Vec<HttpRequestHeader> {
|
||||
vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "User-Agent".to_string(),
|
||||
value: "yaak".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Accept".to_string(),
|
||||
value: "*/*".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::init_in_memory;
|
||||
|
||||
#[test]
|
||||
fn bootstraps_first_workspace_with_real_defaults() {
|
||||
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
|
||||
let db = query_manager.connect();
|
||||
|
||||
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
|
||||
let workspace = workspaces.first().expect("No workspace was bootstrapped");
|
||||
|
||||
// This workspace is built in Rust and never deserialized, so it only gets
|
||||
// these values if `Workspace::default()` carries them. Asserted through the
|
||||
// DB round trip, since the column values are what a fresh install lives with.
|
||||
assert!(workspace.setting_send_cookies, "setting_send_cookies");
|
||||
assert!(workspace.setting_store_cookies, "setting_store_cookies");
|
||||
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
|
||||
assert!(workspace.setting_validate_certificates, "setting_validate_certificates");
|
||||
assert_eq!(
|
||||
workspace.setting_request_message_size,
|
||||
crate::models::DEFAULT_REQUEST_MESSAGE_SIZE
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,159 @@
|
||||
use crate::models::{Environment, EnvironmentVariable};
|
||||
use std::collections::HashMap;
|
||||
//! Rendering requests against an environment chain.
|
||||
//!
|
||||
//! Lives here rather than beside the send engine so that the browser's wasm
|
||||
//! host, which has the model layer but no sockets, renders exactly what the
|
||||
//! desktop renders.
|
||||
|
||||
use crate::models::{
|
||||
Environment, EnvironmentVariable, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
|
||||
};
|
||||
use crate::path_placeholders::apply_path_placeholders;
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
/// Render every template in an HTTP request against an environment chain.
|
||||
pub async fn render_http_request<T: TemplateCallback>(
|
||||
request: &HttpRequest,
|
||||
environment_chain: Vec<Environment>,
|
||||
callback: &T,
|
||||
options: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<HttpRequest> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut url_parameters = Vec::new();
|
||||
for parameter in request.url_parameters.clone() {
|
||||
if !parameter.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
url_parameters.push(HttpUrlParameter {
|
||||
enabled: parameter.enabled,
|
||||
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
|
||||
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
|
||||
id: parameter.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for header in request.headers.clone() {
|
||||
if !header.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.push(HttpRequestHeader {
|
||||
enabled: header.enabled,
|
||||
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
|
||||
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
|
||||
id: header.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut body = BTreeMap::new();
|
||||
for (key, value) in request.body.clone() {
|
||||
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
|
||||
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
|
||||
match request.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(template)) => {
|
||||
disabled = parse_and_render(template.as_str(), vars, callback, options)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
info!(
|
||||
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (key, value) in request.authentication.clone() {
|
||||
if key == "disabled" {
|
||||
auth.insert(key, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
|
||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||
|
||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
|
||||
}
|
||||
|
||||
pub async fn render_grpc_request<T: TemplateCallback>(
|
||||
r: &GrpcRequest,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<GrpcRequest> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
for p in r.metadata.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
metadata.push(HttpRequestHeader {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
match r.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(tmpl)) => {
|
||||
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
info!(
|
||||
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (k, v) in r.authentication.clone() {
|
||||
if k == "disabled" {
|
||||
auth.insert(k, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
|
||||
|
||||
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
|
||||
}
|
||||
|
||||
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
|
||||
let mut variables = HashMap::new();
|
||||
@@ -27,3 +181,70 @@ fn add_variable_to_map(
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn strip_disabled_form_entries(v: Value) -> Value {
|
||||
match v {
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
|
||||
.collect(),
|
||||
),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries() {
|
||||
let input = json!([
|
||||
{"enabled": true, "name": "foo", "value": "bar"},
|
||||
{"enabled": false, "name": "disabled", "value": "gone"},
|
||||
{"enabled": true, "name": "baz", "value": "qux"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([
|
||||
{"enabled": true, "name": "foo", "value": "bar"},
|
||||
{"enabled": true, "name": "baz", "value": "qux"},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_all_disabled() {
|
||||
let input = json!([
|
||||
{"enabled": false, "name": "a", "value": "b"},
|
||||
{"enabled": false, "name": "c", "value": "d"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(result, json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
|
||||
let input = json!([
|
||||
{"name": "no_enabled_field", "value": "kept"},
|
||||
{"enabled": false, "name": "disabled", "value": "gone"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([
|
||||
{"name": "no_enabled_field", "value": "kept"},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_non_array_passthrough() {
|
||||
let input = json!("just a string");
|
||||
let result = strip_disabled_form_entries(input.clone());
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
}
|
||||
|
||||
+58
-2
File diff suppressed because one or more lines are too long
-1
@@ -224,7 +224,6 @@ export type HttpResponse = {
|
||||
updatedAt: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
bodyPath: string | null;
|
||||
contentLength: number | null;
|
||||
contentLengthCompressed: number | null;
|
||||
elapsed: number;
|
||||
|
||||
@@ -171,6 +171,12 @@ pub enum InternalEventPayload {
|
||||
|
||||
FindHttpResponsesRequest(FindHttpResponsesRequest),
|
||||
FindHttpResponsesResponse(FindHttpResponsesResponse),
|
||||
|
||||
GetHttpResponseBodyInfoRequest(GetHttpResponseBodyInfoRequest),
|
||||
GetHttpResponseBodyInfoResponse(GetHttpResponseBodyInfoResponse),
|
||||
ReadHttpResponseBodyChunkRequest(ReadHttpResponseBodyChunkRequest),
|
||||
ReadHttpResponseBodyChunkResponse(ReadHttpResponseBodyChunkResponse),
|
||||
|
||||
ListHttpRequestsRequest(ListHttpRequestsRequest),
|
||||
ListHttpRequestsResponse(ListHttpRequestsResponse),
|
||||
ListFoldersRequest(ListFoldersRequest),
|
||||
@@ -288,6 +294,15 @@ pub struct SendHttpRequestRequest {
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct SendHttpRequestResponse {
|
||||
pub http_response: HttpResponse,
|
||||
|
||||
/// The body, base64, when the send saved nothing.
|
||||
///
|
||||
/// A request with no id behind it produces a response the model store never
|
||||
/// sees, so it cannot be read back by id later the way a saved one can.
|
||||
/// This is the only copy of it. `None` means the body was stored and should
|
||||
/// be read with `read_http_response_body_chunk_request`.
|
||||
#[ts(optional = nullable)]
|
||||
pub body: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
@@ -1413,6 +1428,67 @@ pub struct FindHttpResponsesResponse {
|
||||
pub http_responses: Vec<HttpResponse>,
|
||||
}
|
||||
|
||||
/// Ask what a response's body is, before deciding whether to pull it.
|
||||
///
|
||||
/// Bodies are addressed by response id and never by path, so where the host
|
||||
/// keeps the bytes is its own business.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct GetHttpResponseBodyInfoRequest {
|
||||
pub response_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct GetHttpResponseBodyInfoResponse {
|
||||
/// How many bytes are stored right now, which is not necessarily what the
|
||||
/// `Content-Length` header claimed. Zero when the response has no body.
|
||||
#[ts(type = "number")]
|
||||
pub content_length: u64,
|
||||
|
||||
/// Whether the response has finished arriving. While it has not, the body
|
||||
/// keeps growing past `content_length`, and a reader that wants all of it
|
||||
/// asks again.
|
||||
pub complete: bool,
|
||||
|
||||
/// The response's `Content-Type` header, verbatim, so the reader can pick a
|
||||
/// charset.
|
||||
#[ts(optional = nullable)]
|
||||
pub content_type: Option<String>,
|
||||
}
|
||||
|
||||
/// Pull one window of a response body.
|
||||
///
|
||||
/// Reads are idempotent: the bytes live in durable storage, so the same window
|
||||
/// can be asked for as many times as the plugin likes.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ReadHttpResponseBodyChunkRequest {
|
||||
pub response_id: String,
|
||||
#[ts(type = "number")]
|
||||
pub offset: u64,
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
pub struct ReadHttpResponseBodyChunkResponse {
|
||||
/// Base64, because the desktop transport is a WebSocket that only sends
|
||||
/// text frames today. A host that can carry binary sends the bytes as they
|
||||
/// are and fills this in from them.
|
||||
pub data: String,
|
||||
|
||||
/// Bytes decoded from `data`. Short of the requested length means the body
|
||||
/// ended here.
|
||||
#[ts(type = "number")]
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_events.ts")]
|
||||
|
||||
@@ -10,6 +10,13 @@ wasm-opt = false # Causes errors in CI (haven't figured out why yet)
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[features]
|
||||
default = ["wasm"]
|
||||
# The `#[wasm_bindgen]` exports (parse_template etc.) that make up the
|
||||
# @yaakapp-internal/templates package. Off for crates that link this one into
|
||||
# their own wasm module and do not want these re-exported from theirs.
|
||||
wasm = []
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod format_json;
|
||||
pub mod parser;
|
||||
pub mod renderer;
|
||||
pub mod strip_json_comments;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
pub use parser::*;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
@@ -25,7 +25,10 @@ crate-type = ["cdylib", "rlib"]
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
yaak-lifecycle = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
|
||||
yaak-templates = { path = "../yaak-templates", default-features = false }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
@@ -35,3 +38,4 @@ sqlite-wasm-rs = "0.5"
|
||||
sqlite-wasm-vfs = "0.2"
|
||||
wasm-bindgen = "0.2.100"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["console"] }
|
||||
@@ -3,4 +3,4 @@
|
||||
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
|
||||
// nowhere else: it owns a SQLite database, and there must be exactly one of it
|
||||
// per origin.
|
||||
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
|
||||
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"name": "@yaakapp-internal/wasm",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "yaak-wasm",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_wasm_bg.wasm",
|
||||
"yaak_wasm.js",
|
||||
"yaak_wasm_bg.js",
|
||||
"yaak_wasm.d.ts"
|
||||
],
|
||||
"main": "yaak_wasm.js",
|
||||
"types": "yaak_wasm.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_wasm.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function blob_delete(id: string): void;
|
||||
|
||||
/**
|
||||
* The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
* own id for its body, `{responseId}.request` for the request that produced
|
||||
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
*/
|
||||
export function blob_get(id: string): Uint8Array | undefined;
|
||||
|
||||
/**
|
||||
* Store bytes under an id, replacing anything already there. Chunked the way
|
||||
* the desktop chunks, so a body written here reads back on a desktop that
|
||||
* imports the database, and vice versa.
|
||||
*/
|
||||
export function blob_put(id: string, bytes: Uint8Array): void;
|
||||
|
||||
/**
|
||||
* Register the IndexedDB-backed VFS and open the database.
|
||||
*
|
||||
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
* Safe to call more than once; later calls are no-ops.
|
||||
*/
|
||||
export function boot(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
* request on the network: the environment chain, inherited headers and auth, request
|
||||
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
* posts to the Yaak server.
|
||||
*
|
||||
* Refuses, with a message the user can act on, when the request needs something this host
|
||||
* doesn't have: an authentication plugin, or a template function.
|
||||
*/
|
||||
export function prepare_http_send(payload: any): Promise<any>;
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
*/
|
||||
export function rpc(cmd: string, payload: any, label: string): any;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @ts-self-types="./yaak_wasm.d.ts" */
|
||||
import * as wasm from "./yaak_wasm_bg.wasm";
|
||||
import { __wbg_set_wasm } from "./yaak_wasm_bg.js";
|
||||
|
||||
__wbg_set_wasm(wasm);
|
||||
wasm.__wbindgen_start();
|
||||
export {
|
||||
blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc
|
||||
} from "./yaak_wasm_bg.js";
|
||||
@@ -62,13 +62,44 @@ export function boot() {
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
* request on the network: the environment chain, inherited headers and auth, request
|
||||
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
* posts to the Yaak server.
|
||||
*
|
||||
* Refuses, with a message the user can act on, when the request needs something this host
|
||||
* doesn't have: an authentication plugin, or a template function.
|
||||
* @param {any} payload
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export function prepare_http_send(payload) {
|
||||
const ret = wasm.prepare_http_send(payload);
|
||||
return ret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema` — that
|
||||
* crate itself pulls the git, gRPC and plugin crates for their response types
|
||||
* and cannot come to wasm, so the handful needed here are declared locally.
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema`, but are
|
||||
* declared locally and dispatched by name, which is the one place this host
|
||||
* does not share the desktop's guarantees: the desktop builds its router from
|
||||
* the schema, so every command has a handler by construction. Here a renamed
|
||||
* command would surface as a runtime "not a command this host answers".
|
||||
*
|
||||
* The fix is `yaak-commands` (the `Host` trait), not more machinery here —
|
||||
* its `models::*` handlers are already this file, typed. Three things have to
|
||||
* give before a wasm host can register them:
|
||||
*
|
||||
* 1. `Host: Send + Sync`, which a browser cannot satisfy: there is one thread
|
||||
* and the connection pool is an `Rc`.
|
||||
* 2. `models_delete` reaches for `spawn_blocking`; there is nothing to spawn
|
||||
* onto here.
|
||||
* 3. `yaak-commands` depends on `yaak` and `yaak-plugins`, which pull the HTTP
|
||||
* stack and the Node sidecar and do not build for wasm32.
|
||||
*
|
||||
* None of those are hard; they are just not this PR.
|
||||
* @param {string} cmd
|
||||
* @param {any} payload
|
||||
* @param {string} label
|
||||
@@ -481,7 +512,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
|
||||
return wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
@@ -662,24 +693,27 @@ export function __wbg_versions_215a3ab1c9d5745a(arg0) {
|
||||
const ret = arg0.versions;
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
|
||||
console.warn(arg0, arg1);
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha579407f9663b071);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000005(arg0) {
|
||||
@@ -716,30 +750,30 @@ export function __wbindgen_init_externref_table() {
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2);
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha579407f9663b071(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+6
-5
@@ -5,6 +5,7 @@ export const blob_delete: (a: number, b: number) => [number, number];
|
||||
export const blob_get: (a: number, b: number) => [number, number, number, number];
|
||||
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const boot: () => any;
|
||||
export const prepare_http_send: (a: any) => any;
|
||||
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
|
||||
export const rust_sqlite_wasm_abort: () => void;
|
||||
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
|
||||
@@ -16,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
|
||||
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
|
||||
export const sqlite3_os_end: () => number;
|
||||
export const sqlite3_os_init: () => number;
|
||||
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha579407f9663b071: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
|
||||
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_exn_store: (a: number) => void;
|
||||
@@ -12,8 +12,10 @@
|
||||
//! JavaScript side owns that; this crate assumes it is the only writer.
|
||||
//!
|
||||
//! The command surface is deliberately narrow: what the frontend needs to keep
|
||||
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
|
||||
//! everything else with a socket or a filesystem behind it lives elsewhere.
|
||||
//! its model store coherent, blob storage, and the "prepare" half of a send
|
||||
//! (resolve, inherit, render — see [`prepare_http_send`]). Putting bytes on the
|
||||
//! network, plugins, git, sync and everything else with a socket or a
|
||||
//! filesystem behind it lives elsewhere.
|
||||
|
||||
// Nothing in here means anything off wasm32, and building it there would drag
|
||||
// SQLite's wasm C shim into a native compile. So on any other target the crate
|
||||
@@ -24,12 +26,19 @@ use std::cell::RefCell;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::cookies::apply_cookie_changes;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
};
|
||||
use yaak_models::models_ops;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{ModelPayload, UpdateSource};
|
||||
use yaak_templates::{RenderOptions, TemplateCallback};
|
||||
|
||||
/// Names inside the VFS, not paths on any disk. Two files because the desktop
|
||||
/// keeps two: models in one, blobs in the other.
|
||||
@@ -43,6 +52,10 @@ struct Host {
|
||||
events: mpsc::Receiver<ModelPayload>,
|
||||
}
|
||||
|
||||
fn lifecycle_host() -> yaak_lifecycle::Host {
|
||||
yaak_lifecycle::Host::owner()
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
|
||||
}
|
||||
@@ -91,6 +104,10 @@ pub async fn boot() -> Result<()> {
|
||||
let (queries, blobs, events) =
|
||||
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
|
||||
|
||||
if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) {
|
||||
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
|
||||
}
|
||||
|
||||
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
|
||||
Ok(())
|
||||
}
|
||||
@@ -201,6 +218,28 @@ struct UpsertIntrospectionReq {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResponseIdReq {
|
||||
response_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PersistSendCookiesReq {
|
||||
cookie_jar_id: String,
|
||||
before: Vec<Cookie>,
|
||||
after: Vec<Cookie>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct InsertResponseEventsReq {
|
||||
response_id: String,
|
||||
workspace_id: String,
|
||||
events: Vec<HttpResponseEventData>,
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
host: &Host,
|
||||
cmd: &str,
|
||||
@@ -305,6 +344,48 @@ fn dispatch(
|
||||
// Nothing here can open a socket, so no connection ever produced any.
|
||||
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
|
||||
|
||||
"web_get_http_request" => {
|
||||
let req: RequestIdReq = from_js(payload)?;
|
||||
to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"cmd_get_http_response_events" => {
|
||||
let req: ResponseIdReq = from_js(payload)?;
|
||||
to_json(
|
||||
host.queries
|
||||
.connect()
|
||||
.list_http_response_events(&req.response_id)
|
||||
.map_err(js_error)?,
|
||||
)
|
||||
}
|
||||
|
||||
// The cookies a send set or cleared, applied to the jar as it is *now* rather than
|
||||
// written over it, so an edit made while the send was in flight survives.
|
||||
"web_persist_send_cookies" => {
|
||||
let req: PersistSendCookiesReq = from_js(payload)?;
|
||||
if req.before == req.after {
|
||||
return to_json(());
|
||||
}
|
||||
let db = host.queries.connect();
|
||||
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
|
||||
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
|
||||
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
// The tab's half of the send timeline: the events the proxy streamed back, recorded
|
||||
// under the response they belong to. Same rows the desktop's send task writes, and the
|
||||
// writes fan out to every tab as `model_writes` like any other.
|
||||
"web_insert_http_response_events" => {
|
||||
let req: InsertResponseEventsReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
for event in req.events {
|
||||
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
|
||||
db.upsert_http_response_event(&model, source).map_err(js_error)?;
|
||||
}
|
||||
to_json(())
|
||||
}
|
||||
|
||||
"cmd_get_workspace_meta" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
let db = host.queries.connect();
|
||||
@@ -312,10 +393,157 @@ fn dispatch(
|
||||
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
|
||||
}
|
||||
|
||||
"cmd_delete_all_http_responses" => {
|
||||
let req: RequestIdReq = from_js(payload)?;
|
||||
host.queries
|
||||
.connect()
|
||||
.delete_all_http_responses_for_request(&req.request_id, source)
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
"cmd_delete_send_history" => {
|
||||
let req: WorkspaceIdReq = from_js(payload)?;
|
||||
host.queries
|
||||
.with_tx(|tx| {
|
||||
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
|
||||
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
|
||||
Ok::<(), yaak_models::error::Error>(())
|
||||
})
|
||||
.map_err(js_error)?;
|
||||
to_json(())
|
||||
}
|
||||
|
||||
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Preparing a send */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PrepareHttpSendReq {
|
||||
request_id: String,
|
||||
environment_id: Option<String>,
|
||||
cookie_jar_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Everything a send needs that lives in the database, resolved and rendered: the desktop's
|
||||
/// `HttpSendInputs`, in the shape a tab hands to the proxy and keeps for itself.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PreparedHttpSend {
|
||||
/// The request with inherited headers and authentication applied and every template
|
||||
/// rendered. What the proxy sends, and what the response records as its request.
|
||||
request: HttpRequest,
|
||||
settings: HttpSendSettings,
|
||||
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
|
||||
/// sources and all. The tab records them before the proxy's own events.
|
||||
setting_events: Vec<HttpResponseEventData>,
|
||||
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
|
||||
cookie_jar: Option<CookieJar>,
|
||||
}
|
||||
|
||||
/// A template callback for a host with no plugins. Variables render; a function is a clear
|
||||
/// refusal naming the function, so the user knows what the request needs rather than seeing
|
||||
/// an empty string sent in its place.
|
||||
struct NoPluginsCallback;
|
||||
|
||||
impl TemplateCallback for NoPluginsCallback {
|
||||
fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
_args: HashMap<String, serde_json::Value>,
|
||||
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
|
||||
let message = format!(
|
||||
"This request uses the template function \"{fn_name}\", which needs plugins. \
|
||||
Plugins aren't available in the browser yet"
|
||||
);
|
||||
async move { Err(yaak_templates::error::Error::RenderError(message)) }
|
||||
}
|
||||
|
||||
fn transform_arg(
|
||||
&self,
|
||||
_fn_name: &str,
|
||||
_arg_name: &str,
|
||||
arg_value: &str,
|
||||
) -> yaak_templates::error::Result<String> {
|
||||
Ok(arg_value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
|
||||
/// request on the network: the environment chain, inherited headers and auth, request
|
||||
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
|
||||
/// posts to the Yaak server.
|
||||
///
|
||||
/// Refuses, with a message the user can act on, when the request needs something this host
|
||||
/// doesn't have: an authentication plugin, or a template function.
|
||||
#[wasm_bindgen]
|
||||
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
|
||||
let req: PrepareHttpSendReq = from_js(payload)?;
|
||||
|
||||
// Everything from the database first, then release the host borrow before rendering.
|
||||
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
|
||||
let db = host.queries.connect();
|
||||
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
|
||||
let environment_chain = db
|
||||
.resolve_environments(
|
||||
&request.workspace_id,
|
||||
request.folder_id.as_deref(),
|
||||
req.environment_id.as_deref(),
|
||||
)
|
||||
.map_err(js_error)?;
|
||||
let (authentication_type, authentication, _auth_context_id) =
|
||||
db.resolve_auth_for_http_request(&request).map_err(js_error)?;
|
||||
let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?;
|
||||
let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?;
|
||||
let cookie_jar = match req.cookie_jar_id.as_deref() {
|
||||
Some(id) => Some(db.get_cookie_jar(id).map_err(js_error)?),
|
||||
None => None,
|
||||
};
|
||||
let request = HttpRequest { authentication_type, authentication, headers, ..request };
|
||||
Ok((request, environment_chain, settings, cookie_jar))
|
||||
})?;
|
||||
|
||||
let rendered = render_http_request(
|
||||
&request,
|
||||
environment_chain,
|
||||
&NoPluginsCallback,
|
||||
&RenderOptions::throw(),
|
||||
)
|
||||
.await
|
||||
.map_err(js_error)?;
|
||||
|
||||
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
|
||||
// request sent without the auth it asked for is worse than one refused with the reason.
|
||||
let auth_disabled =
|
||||
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
|
||||
if let Some(auth_type) = rendered.authentication_type.as_deref()
|
||||
&& auth_type != "none"
|
||||
&& !auth_disabled
|
||||
{
|
||||
return Err(js_error(format!(
|
||||
"This request uses {auth_type} authentication, which needs plugins. \
|
||||
Plugins aren't available in the browser yet"
|
||||
)));
|
||||
}
|
||||
|
||||
let prepared = PreparedHttpSend {
|
||||
request: rendered,
|
||||
settings: HttpSendSettings::from(&settings),
|
||||
setting_events: settings.timeline_events(),
|
||||
cookie_jar,
|
||||
};
|
||||
// JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`,
|
||||
// and the default serializer's `Map` for the request body would stringify to `{}`.
|
||||
use serde::Serialize as _;
|
||||
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Blobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"name": "yaak-web",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_web_bg.wasm",
|
||||
"yaak_web.js",
|
||||
"yaak_web_bg.js",
|
||||
"yaak_web.d.ts"
|
||||
],
|
||||
"main": "yaak_web.js",
|
||||
"types": "yaak_web.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_web.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
Vendored
-36
@@ -1,36 +0,0 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
|
||||
export function blob_delete(id: string): void;
|
||||
|
||||
/**
|
||||
* The bytes stored under an id, or none. Ids are the desktop's: a response's
|
||||
* own id for its body, `{responseId}.request` for the request that produced
|
||||
* it. Bytes cross to JS as a `Uint8Array` rather than through JSON.
|
||||
*/
|
||||
export function blob_get(id: string): Uint8Array | undefined;
|
||||
|
||||
/**
|
||||
* Store bytes under an id, replacing anything already there. Chunked the way
|
||||
* the desktop chunks, so a body written here reads back on a desktop that
|
||||
* imports the database, and vice versa.
|
||||
*/
|
||||
export function blob_put(id: string, bytes: Uint8Array): void;
|
||||
|
||||
/**
|
||||
* Register the IndexedDB-backed VFS and open the database.
|
||||
*
|
||||
* Migrations run inside `init_standalone`, exactly as they do for the CLI.
|
||||
* Safe to call more than once; later calls are no-ops.
|
||||
*/
|
||||
export function boot(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Run one command as `label` (the calling tab's identity, which stands in for
|
||||
* the desktop's window label on every write it makes).
|
||||
*
|
||||
* The payload shapes match the `Cmd*Req` types in `yaak-rpc-schema` — that
|
||||
* crate itself pulls the git, gRPC and plugin crates for their response types
|
||||
* and cannot come to wasm, so the handful needed here are declared locally.
|
||||
*/
|
||||
export function rpc(cmd: string, payload: any, label: string): any;
|
||||
@@ -1,6 +0,0 @@
|
||||
import init from "./yaak_web_bg.wasm?init";
|
||||
export * from "./yaak_web_bg.js";
|
||||
import * as bg from "./yaak_web_bg.js";
|
||||
const instance = await init({ "./yaak_web_bg.js": bg });
|
||||
bg.__wbg_set_wasm(instance.exports);
|
||||
instance.exports.__wbindgen_start();
|
||||
Binary file not shown.
@@ -6,6 +6,7 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -2,7 +2,7 @@ pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
pub use error::Error;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::response_body::ResponseBodyStore;
|
||||
use base64::Engine;
|
||||
use base64::prelude::BASE64_STANDARD;
|
||||
use yaak_models::models::AnyModel;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::util::UpdateSource;
|
||||
@@ -5,12 +8,14 @@ use yaak_plugins::events::{
|
||||
CloseWindowRequest, CopyTextRequest, DeleteKeyValueRequest, DeleteKeyValueResponse,
|
||||
DeleteModelRequest, DeleteModelResponse, ErrorResponse, FindHttpResponsesRequest,
|
||||
FindHttpResponsesResponse, GetCookieValueRequest, GetHttpRequestByIdRequest,
|
||||
GetHttpRequestByIdResponse, GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload,
|
||||
ListCookieNamesRequest, ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse, ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest,
|
||||
PromptFormRequest, PromptTextRequest, ReloadResponse, RenderGrpcRequestRequest,
|
||||
RenderHttpRequestRequest, SendHttpRequestRequest, SetKeyValueRequest, ShowToastRequest,
|
||||
TemplateRenderRequest, UpsertModelRequest, UpsertModelResponse, WindowInfoRequest,
|
||||
GetHttpRequestByIdResponse, GetHttpResponseBodyInfoRequest, GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueRequest, GetKeyValueResponse, InternalEventPayload, ListCookieNamesRequest,
|
||||
ListFoldersRequest, ListFoldersResponse, ListHttpRequestsRequest, ListHttpRequestsResponse,
|
||||
ListOpenWorkspacesRequest, OpenExternalUrlRequest, OpenWindowRequest, PromptFormRequest,
|
||||
PromptTextRequest, ReadHttpResponseBodyChunkRequest, ReadHttpResponseBodyChunkResponse,
|
||||
ReloadResponse, RenderGrpcRequestRequest, RenderHttpRequestRequest, SendHttpRequestRequest,
|
||||
SetKeyValueRequest, ShowToastRequest, TemplateRenderRequest, UpsertModelRequest,
|
||||
UpsertModelResponse, WindowInfoRequest,
|
||||
};
|
||||
|
||||
pub struct SharedPluginEventContext<'a> {
|
||||
@@ -40,6 +45,8 @@ pub enum SharedRequest<'a> {
|
||||
ListFolders(&'a ListFoldersRequest),
|
||||
ListHttpRequests(&'a ListHttpRequestsRequest),
|
||||
FindHttpResponses(&'a FindHttpResponsesRequest),
|
||||
GetHttpResponseBodyInfo(&'a GetHttpResponseBodyInfoRequest),
|
||||
ReadHttpResponseBodyChunk(&'a ReadHttpResponseBodyChunkRequest),
|
||||
UpsertModel(&'a UpsertModelRequest),
|
||||
DeleteModel(&'a DeleteModelRequest),
|
||||
}
|
||||
@@ -136,6 +143,12 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
||||
InternalEventPayload::FindHttpResponsesRequest(req) => {
|
||||
GroupedPluginRequest::Shared(SharedRequest::FindHttpResponses(req))
|
||||
}
|
||||
InternalEventPayload::GetHttpResponseBodyInfoRequest(req) => {
|
||||
GroupedPluginRequest::Shared(SharedRequest::GetHttpResponseBodyInfo(req))
|
||||
}
|
||||
InternalEventPayload::ReadHttpResponseBodyChunkRequest(req) => {
|
||||
GroupedPluginRequest::Shared(SharedRequest::ReadHttpResponseBodyChunk(req))
|
||||
}
|
||||
InternalEventPayload::UpsertModelRequest(req) => {
|
||||
GroupedPluginRequest::Shared(SharedRequest::UpsertModel(req))
|
||||
}
|
||||
@@ -182,13 +195,17 @@ impl<'a> From<&'a InternalEventPayload> for GroupedPluginRequest<'a> {
|
||||
|
||||
pub fn handle_shared_plugin_event<'a>(
|
||||
query_manager: &QueryManager,
|
||||
body_store: &dyn ResponseBodyStore,
|
||||
payload: &'a InternalEventPayload,
|
||||
context: SharedPluginEventContext<'_>,
|
||||
) -> GroupedPluginEvent<'a> {
|
||||
match GroupedPluginRequest::from(payload) {
|
||||
GroupedPluginRequest::Shared(req) => {
|
||||
GroupedPluginEvent::Handled(Some(build_shared_reply(query_manager, req, context)))
|
||||
}
|
||||
GroupedPluginRequest::Shared(req) => GroupedPluginEvent::Handled(Some(build_shared_reply(
|
||||
query_manager,
|
||||
body_store,
|
||||
req,
|
||||
context,
|
||||
))),
|
||||
GroupedPluginRequest::Host(req) => GroupedPluginEvent::ToHandle(req),
|
||||
GroupedPluginRequest::Ignore => GroupedPluginEvent::Handled(None),
|
||||
}
|
||||
@@ -196,6 +213,7 @@ pub fn handle_shared_plugin_event<'a>(
|
||||
|
||||
fn build_shared_reply(
|
||||
query_manager: &QueryManager,
|
||||
body_store: &dyn ResponseBodyStore,
|
||||
request: SharedRequest<'_>,
|
||||
context: SharedPluginEventContext<'_>,
|
||||
) -> InternalEventPayload {
|
||||
@@ -283,6 +301,31 @@ fn build_shared_reply(
|
||||
http_responses,
|
||||
})
|
||||
}
|
||||
SharedRequest::GetHttpResponseBodyInfo(req) => match body_store.info(&req.response_id) {
|
||||
Ok(info) => InternalEventPayload::GetHttpResponseBodyInfoResponse(
|
||||
GetHttpResponseBodyInfoResponse {
|
||||
content_length: info.content_length,
|
||||
content_type: info.content_type,
|
||||
complete: info.complete,
|
||||
},
|
||||
),
|
||||
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to read body of response {}: {err}", req.response_id),
|
||||
}),
|
||||
},
|
||||
SharedRequest::ReadHttpResponseBodyChunk(req) => {
|
||||
match body_store.read_chunk(&req.response_id, req.offset, req.length) {
|
||||
Ok(bytes) => InternalEventPayload::ReadHttpResponseBodyChunkResponse(
|
||||
ReadHttpResponseBodyChunkResponse {
|
||||
length: bytes.len() as u64,
|
||||
data: BASE64_STANDARD.encode(bytes),
|
||||
},
|
||||
),
|
||||
Err(err) => InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: format!("Failed to read body of response {}: {err}", req.response_id),
|
||||
}),
|
||||
}
|
||||
}
|
||||
SharedRequest::UpsertModel(req) => {
|
||||
use AnyModel::*;
|
||||
|
||||
@@ -437,10 +480,26 @@ fn build_shared_reply(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
|
||||
use std::cell::RefCell;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
/// The real dispatch, with the store the desktop and CLI hand it.
|
||||
fn dispatch<'a>(
|
||||
query_manager: &QueryManager,
|
||||
payload: &'a InternalEventPayload,
|
||||
context: SharedPluginEventContext<'_>,
|
||||
) -> GroupedPluginEvent<'a> {
|
||||
handle_shared_plugin_event(
|
||||
query_manager,
|
||||
&FileResponseBodyStore::new(query_manager),
|
||||
payload,
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
fn seed_query_manager() -> (QueryManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("db.sqlite");
|
||||
@@ -498,7 +557,7 @@ mod tests {
|
||||
let payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
);
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
@@ -517,7 +576,7 @@ mod tests {
|
||||
let by_workspace_payload = InternalEventPayload::ListHttpRequestsRequest(
|
||||
yaak_plugins::events::ListHttpRequestsRequest { folder_id: None },
|
||||
);
|
||||
let by_workspace = handle_shared_plugin_event(
|
||||
let by_workspace = dispatch(
|
||||
&query_manager,
|
||||
&by_workspace_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -536,7 +595,7 @@ mod tests {
|
||||
folder_id: Some("fl_test".to_string()),
|
||||
},
|
||||
);
|
||||
let by_folder = handle_shared_plugin_event(
|
||||
let by_folder = dispatch(
|
||||
&query_manager,
|
||||
&by_folder_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
@@ -559,7 +618,7 @@ mod tests {
|
||||
limit: Some(1),
|
||||
});
|
||||
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -575,6 +634,105 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A store that answers from memory, standing in for whatever holds the
|
||||
/// bytes — the point being that the dispatch below never learns which.
|
||||
struct FakeBodyStore {
|
||||
body: Vec<u8>,
|
||||
reads: RefCell<Vec<(u64, u64)>>,
|
||||
}
|
||||
|
||||
impl ResponseBodyStore for FakeBodyStore {
|
||||
fn info(&self, _response_id: &str) -> crate::error::Result<ResponseBodyInfo> {
|
||||
Ok(ResponseBodyInfo {
|
||||
content_length: self.body.len() as u64,
|
||||
content_type: Some("text/plain; charset=utf-8".to_string()),
|
||||
complete: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_chunk(
|
||||
&self,
|
||||
_response_id: &str,
|
||||
offset: u64,
|
||||
length: u64,
|
||||
) -> crate::error::Result<Vec<u8>> {
|
||||
self.reads.borrow_mut().push((offset, length));
|
||||
let start = (offset as usize).min(self.body.len());
|
||||
let end = (start + length as usize).min(self.body.len());
|
||||
Ok(self.body[start..end].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_body_is_read_by_id_through_the_store() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let store = FakeBodyStore { body: b"hello".to_vec(), reads: RefCell::new(Vec::new()) };
|
||||
|
||||
let info_payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_test".to_string() },
|
||||
);
|
||||
let info = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&store,
|
||||
&info_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
);
|
||||
match info {
|
||||
GroupedPluginEvent::Handled(Some(
|
||||
InternalEventPayload::GetHttpResponseBodyInfoResponse(resp),
|
||||
)) => {
|
||||
assert_eq!(resp.content_length, 5);
|
||||
assert_eq!(resp.content_type.as_deref(), Some("text/plain; charset=utf-8"));
|
||||
}
|
||||
other => panic!("unexpected body info result: {other:?}"),
|
||||
}
|
||||
|
||||
let chunk_payload = InternalEventPayload::ReadHttpResponseBodyChunkRequest(
|
||||
ReadHttpResponseBodyChunkRequest {
|
||||
response_id: "rs_test".to_string(),
|
||||
offset: 1,
|
||||
length: 3,
|
||||
},
|
||||
);
|
||||
let chunk = handle_shared_plugin_event(
|
||||
&query_manager,
|
||||
&store,
|
||||
&chunk_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
);
|
||||
match chunk {
|
||||
GroupedPluginEvent::Handled(Some(
|
||||
InternalEventPayload::ReadHttpResponseBodyChunkResponse(resp),
|
||||
)) => {
|
||||
assert_eq!(resp.length, 3);
|
||||
assert_eq!(BASE64_STANDARD.decode(resp.data).unwrap(), b"ell");
|
||||
}
|
||||
other => panic!("unexpected body chunk result: {other:?}"),
|
||||
}
|
||||
|
||||
assert_eq!(*store.reads.borrow(), vec![(1, 3)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_response_body_becomes_an_error_reply() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
let payload = InternalEventPayload::GetHttpResponseBodyInfoRequest(
|
||||
GetHttpResponseBodyInfoRequest { response_id: "rs_never_persisted".to_string() },
|
||||
);
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
);
|
||||
|
||||
match result {
|
||||
GroupedPluginEvent::Handled(Some(InternalEventPayload::ErrorResponse(resp))) => {
|
||||
assert!(resp.error.contains("rs_never_persisted"), "unhelpful error: {}", resp.error)
|
||||
}
|
||||
other => panic!("unexpected missing-response result: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_and_delete_model_are_shared_handled() {
|
||||
let (query_manager, _temp_dir) = seed_query_manager();
|
||||
@@ -590,7 +748,7 @@ mod tests {
|
||||
}),
|
||||
});
|
||||
|
||||
let upsert_result = handle_shared_plugin_event(
|
||||
let upsert_result = dispatch(
|
||||
&query_manager,
|
||||
&upsert_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -609,7 +767,7 @@ mod tests {
|
||||
model: "http_request".to_string(),
|
||||
id: "rq_test".to_string(),
|
||||
});
|
||||
let delete_result = handle_shared_plugin_event(
|
||||
let delete_result = dispatch(
|
||||
&query_manager,
|
||||
&delete_payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: Some("wk_test") },
|
||||
@@ -631,7 +789,7 @@ mod tests {
|
||||
let payload = InternalEventPayload::WindowInfoRequest(WindowInfoRequest {
|
||||
label: "main".to_string(),
|
||||
});
|
||||
let result = handle_shared_plugin_event(
|
||||
let result = dispatch(
|
||||
&query_manager,
|
||||
&payload,
|
||||
SharedPluginEventContext { plugin_name: "@yaak/test", workspace_id: None },
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
use log::info;
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use yaak_http::path_placeholders::apply_path_placeholders;
|
||||
use yaak_models::models::{
|
||||
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
|
||||
};
|
||||
use yaak_models::render::make_vars_hashmap;
|
||||
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
|
||||
|
||||
pub async fn render_http_request<T: TemplateCallback>(
|
||||
request: &HttpRequest,
|
||||
environment_chain: Vec<Environment>,
|
||||
callback: &T,
|
||||
options: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<HttpRequest> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut url_parameters = Vec::new();
|
||||
for parameter in request.url_parameters.clone() {
|
||||
if !parameter.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
url_parameters.push(HttpUrlParameter {
|
||||
enabled: parameter.enabled,
|
||||
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
|
||||
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
|
||||
id: parameter.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut headers = Vec::new();
|
||||
for header in request.headers.clone() {
|
||||
if !header.enabled {
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.push(HttpRequestHeader {
|
||||
enabled: header.enabled,
|
||||
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
|
||||
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
|
||||
id: header.id,
|
||||
})
|
||||
}
|
||||
|
||||
let mut body = BTreeMap::new();
|
||||
for (key, value) in request.body.clone() {
|
||||
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
|
||||
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
|
||||
match request.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(template)) => {
|
||||
disabled = parse_and_render(template.as_str(), vars, callback, options)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
info!(
|
||||
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (key, value) in request.authentication.clone() {
|
||||
if key == "disabled" {
|
||||
auth.insert(key, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
|
||||
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
|
||||
|
||||
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
|
||||
}
|
||||
|
||||
pub async fn render_grpc_request<T: TemplateCallback>(
|
||||
r: &GrpcRequest,
|
||||
environment_chain: Vec<Environment>,
|
||||
cb: &T,
|
||||
opt: &RenderOptions,
|
||||
) -> yaak_templates::error::Result<GrpcRequest> {
|
||||
let vars = &make_vars_hashmap(environment_chain);
|
||||
|
||||
let mut metadata = Vec::new();
|
||||
for p in r.metadata.clone() {
|
||||
if !p.enabled {
|
||||
continue;
|
||||
}
|
||||
metadata.push(HttpRequestHeader {
|
||||
enabled: p.enabled,
|
||||
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
|
||||
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
|
||||
id: p.id,
|
||||
})
|
||||
}
|
||||
|
||||
let authentication = {
|
||||
let mut disabled = false;
|
||||
let mut auth = BTreeMap::new();
|
||||
match r.authentication.get("disabled") {
|
||||
Some(Value::Bool(true)) => {
|
||||
disabled = true;
|
||||
}
|
||||
Some(Value::String(tmpl)) => {
|
||||
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
info!(
|
||||
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if disabled {
|
||||
auth.insert("disabled".to_string(), Value::Bool(true));
|
||||
} else {
|
||||
for (k, v) in r.authentication.clone() {
|
||||
if k == "disabled" {
|
||||
auth.insert(k, Value::Bool(false));
|
||||
} else {
|
||||
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
|
||||
}
|
||||
}
|
||||
}
|
||||
auth
|
||||
};
|
||||
|
||||
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
|
||||
|
||||
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
|
||||
}
|
||||
|
||||
fn strip_disabled_form_entries(v: Value) -> Value {
|
||||
match v {
|
||||
Value::Array(items) => Value::Array(
|
||||
items
|
||||
.into_iter()
|
||||
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
|
||||
.collect(),
|
||||
),
|
||||
v => v,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries() {
|
||||
let input = json!([
|
||||
{"enabled": true, "name": "foo", "value": "bar"},
|
||||
{"enabled": false, "name": "disabled", "value": "gone"},
|
||||
{"enabled": true, "name": "baz", "value": "qux"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([
|
||||
{"enabled": true, "name": "foo", "value": "bar"},
|
||||
{"enabled": true, "name": "baz", "value": "qux"},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_all_disabled() {
|
||||
let input = json!([
|
||||
{"enabled": false, "name": "a", "value": "b"},
|
||||
{"enabled": false, "name": "c", "value": "d"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(result, json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
|
||||
let input = json!([
|
||||
{"name": "no_enabled_field", "value": "kept"},
|
||||
{"enabled": false, "name": "disabled", "value": "gone"},
|
||||
]);
|
||||
let result = strip_disabled_form_entries(input);
|
||||
assert_eq!(
|
||||
result,
|
||||
json!([
|
||||
{"name": "no_enabled_field", "value": "kept"},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_disabled_form_entries_non_array_passthrough() {
|
||||
let input = json!("just a string");
|
||||
let result = strip_disabled_form_entries(input.clone());
|
||||
assert_eq!(result, input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Reading response bodies back out, by response id.
|
||||
//!
|
||||
//! Plugins only ever name a response. Where its bytes actually live — files the
|
||||
//! engine wrote under `<data dir>/responses/<id>` today, blob rows later — is
|
||||
//! behind [`ResponseBodyStore`], so moving the bytes is a change to this file
|
||||
//! and nothing a plugin can see.
|
||||
//!
|
||||
//! Only saved responses are reachable by id. A send that saved nothing hands
|
||||
//! its body back with the reply instead, which is the only copy of it there is.
|
||||
|
||||
use crate::error::Result;
|
||||
use std::fs::File;
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use yaak_models::models::HttpResponseState;
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
|
||||
/// The most bytes one read will hand back, however much was asked for.
|
||||
///
|
||||
/// A chunk is buffered whole and, on the desktop transport, base64'd into a
|
||||
/// single WebSocket frame, so an unbounded request is a way to make the host
|
||||
/// allocate on a plugin's say-so.
|
||||
pub const MAX_CHUNK_BYTES: u64 = 8 * 1024 * 1024;
|
||||
|
||||
/// What a stored body is, without reading any of it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ResponseBodyInfo {
|
||||
/// Bytes actually stored, which is not necessarily what `Content-Length`
|
||||
/// claimed. Zero when the response has no body.
|
||||
pub content_length: u64,
|
||||
/// The response's `Content-Type` header, verbatim.
|
||||
pub content_type: Option<String>,
|
||||
/// Whether the response has finished arriving, so `content_length` is
|
||||
/// final. A body still being written grows past it.
|
||||
pub complete: bool,
|
||||
}
|
||||
|
||||
/// Somewhere response bodies can be read from, a window at a time.
|
||||
///
|
||||
/// Reads are repeatable — the bytes are durable, so nothing is consumed by
|
||||
/// looking at it.
|
||||
pub trait ResponseBodyStore {
|
||||
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo>;
|
||||
|
||||
/// Bytes `[offset, offset + length)`, clamped to what is there. A short
|
||||
/// read means the body ended.
|
||||
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>>;
|
||||
}
|
||||
|
||||
/// The desktop and CLI store: the database says where the file is, and the
|
||||
/// filesystem holds it.
|
||||
pub struct FileResponseBodyStore<'a> {
|
||||
query_manager: &'a QueryManager,
|
||||
}
|
||||
|
||||
impl<'a> FileResponseBodyStore<'a> {
|
||||
pub fn new(query_manager: &'a QueryManager) -> Self {
|
||||
Self { query_manager }
|
||||
}
|
||||
|
||||
/// The file backing a response, or `None` when it stored no body.
|
||||
///
|
||||
/// Only responses the store knows about are reachable here. A send with no
|
||||
/// request behind it never reaches the store at all, and its bytes come
|
||||
/// back from the send instead — see `SendHttpRequestResponse::body`.
|
||||
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
|
||||
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
|
||||
}
|
||||
}
|
||||
|
||||
impl ResponseBodyStore for FileResponseBodyStore<'_> {
|
||||
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
|
||||
let response = self.query_manager.connect().get_http_response(response_id)?;
|
||||
|
||||
let content_type = response
|
||||
.headers
|
||||
.iter()
|
||||
.find(|h| h.name.eq_ignore_ascii_case("content-type"))
|
||||
.map(|h| h.value.clone());
|
||||
|
||||
let content_length = match response.body_path {
|
||||
Some(path) => std::fs::metadata(path)?.len(),
|
||||
None => 0,
|
||||
};
|
||||
|
||||
Ok(ResponseBodyInfo {
|
||||
content_length,
|
||||
content_type,
|
||||
// Closed is the one terminal state: success, error, and cancel all end there.
|
||||
complete: matches!(response.state, HttpResponseState::Closed),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_chunk(&self, response_id: &str, offset: u64, length: u64) -> Result<Vec<u8>> {
|
||||
let Some(path) = self.body_path(response_id)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let length = length.min(MAX_CHUNK_BYTES);
|
||||
if length == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut file = File::open(path)?;
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
|
||||
let mut buf = Vec::new();
|
||||
file.take(length).read_to_end(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&temp_dir.path().join("db.sqlite"),
|
||||
&temp_dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_workspace(
|
||||
&Workspace { id: "wk_test".to_string(), ..Default::default() },
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
query_manager
|
||||
.connect()
|
||||
.upsert_http_request(
|
||||
&HttpRequest {
|
||||
id: "rq_test".to_string(),
|
||||
workspace_id: "wk_test".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let body_path = body.map(|bytes| {
|
||||
let path = temp_dir.path().join("body");
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
f.write_all(bytes).unwrap();
|
||||
path.to_string_lossy().to_string()
|
||||
});
|
||||
|
||||
let response = query_manager
|
||||
.connect()
|
||||
.upsert_http_response(
|
||||
&HttpResponse {
|
||||
workspace_id: "wk_test".to_string(),
|
||||
request_id: "rq_test".to_string(),
|
||||
body_path,
|
||||
headers: vec![HttpResponseHeader {
|
||||
name: "Content-Type".to_string(),
|
||||
value: "application/json; charset=utf-8".to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
&UpdateSource::Sync,
|
||||
&blob_manager,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let id = response.id.clone();
|
||||
(query_manager, temp_dir, id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_reports_stored_size_and_content_type() {
|
||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
|
||||
assert_eq!(info.content_length, 11);
|
||||
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_cover_the_body_and_stop_short_at_the_end() {
|
||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||
let store = FileResponseBodyStore::new(&qm);
|
||||
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
|
||||
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
|
||||
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
|
||||
// Reading the same window twice gives the same bytes; nothing is consumed.
|
||||
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_with_no_body_is_empty_not_an_error() {
|
||||
let (qm, _tmp, id) = seed(None);
|
||||
let store = FileResponseBodyStore::new(&qm);
|
||||
assert_eq!(store.info(&id).unwrap().content_length, 0);
|
||||
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_tracks_whether_the_response_has_closed() {
|
||||
let (qm, _tmp, id) = seed(Some(b"partial"));
|
||||
// Seeded responses default to Initialized: still arriving.
|
||||
assert!(!FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
|
||||
|
||||
let mut response = qm.connect().get_http_response(&id).unwrap();
|
||||
response.state = HttpResponseState::Closed;
|
||||
qm.connect().update_http_response_if_id(&response, &UpdateSource::Sync).unwrap();
|
||||
|
||||
assert!(FileResponseBodyStore::new(&qm).info(&id).unwrap().complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_response_fails() {
|
||||
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
||||
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unsaved_response_is_not_reachable_by_id() {
|
||||
// Its bytes rode back with the send; there is nothing here to find, and
|
||||
// guessing at a file named for the id is exactly what this must not do.
|
||||
let (qm, tmp, _id) = seed(Some(b"hi"));
|
||||
std::fs::write(tmp.path().join("rs_ephemeral1"), b"access_token=abc").unwrap();
|
||||
|
||||
assert!(FileResponseBodyStore::new(&qm).info("rs_ephemeral1").is_err());
|
||||
}
|
||||
}
|
||||
+35
-55
@@ -1,4 +1,3 @@
|
||||
use crate::render::render_http_request;
|
||||
use async_trait::async_trait;
|
||||
use log::warn;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -25,10 +24,11 @@ use yaak_http::types::{
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::{
|
||||
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
|
||||
HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||
ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
|
||||
ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
};
|
||||
use yaak_models::query_manager::QueryManager;
|
||||
use yaak_models::render::render_http_request;
|
||||
use yaak_models::util::{UpdateSource, generate_prefixed_id};
|
||||
use yaak_plugins::events::{
|
||||
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
|
||||
@@ -193,6 +193,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
|
||||
proxy: runtime_config.proxy.clone(),
|
||||
client_certificate,
|
||||
dns_overrides: runtime_config.dns_overrides.clone(),
|
||||
address_filter: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -354,6 +355,19 @@ pub enum ResponseBody {
|
||||
Returned(Vec<u8>),
|
||||
}
|
||||
|
||||
impl ResponseBody {
|
||||
/// The bytes, when this is the only copy of them.
|
||||
///
|
||||
/// Stored and streamed bodies belong to whoever holds them; only `Returned`
|
||||
/// has to travel back to the caller.
|
||||
pub fn returned_bytes(&self) -> Option<&[u8]> {
|
||||
match self {
|
||||
ResponseBody::Returned(bytes) => Some(bytes),
|
||||
ResponseBody::Stored | ResponseBody::Streamed => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SendHttpRequestResult {
|
||||
pub rendered_request: HttpRequest,
|
||||
pub response: HttpResponse,
|
||||
@@ -702,36 +716,24 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
let started_at = Instant::now();
|
||||
let request_started_url = sendable_request.url.clone();
|
||||
|
||||
send_setting_event(
|
||||
&event_tx,
|
||||
"validate_certificates",
|
||||
resolved_settings.validate_certificates.value.to_string(),
|
||||
&resolved_settings.validate_certificates,
|
||||
);
|
||||
send_setting_event(
|
||||
&event_tx,
|
||||
"redirects",
|
||||
sendable_request.options.follow_redirects.to_string(),
|
||||
&resolved_settings.follow_redirects,
|
||||
);
|
||||
send_setting_event(
|
||||
&event_tx,
|
||||
"timeout",
|
||||
timeout_setting_value(sendable_request.options.timeout),
|
||||
&resolved_settings.request_timeout,
|
||||
);
|
||||
send_setting_event(
|
||||
&event_tx,
|
||||
"send_cookies",
|
||||
cookie_behavior.send_cookies.to_string(),
|
||||
&resolved_settings.send_cookies,
|
||||
);
|
||||
send_setting_event(
|
||||
&event_tx,
|
||||
"store_cookies",
|
||||
cookie_behavior.store_cookies.to_string(),
|
||||
&resolved_settings.store_cookies,
|
||||
);
|
||||
for event in resolved_settings.timeline_events() {
|
||||
if let HttpResponseEventData::Setting {
|
||||
name,
|
||||
value,
|
||||
source_model,
|
||||
source_id,
|
||||
source_name,
|
||||
} = event
|
||||
{
|
||||
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
|
||||
name,
|
||||
value,
|
||||
source_model,
|
||||
source_id,
|
||||
source_name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut http_response =
|
||||
match executor.send(sendable_request, event_tx, cookie_behavior.clone()).await {
|
||||
@@ -1117,28 +1119,6 @@ pub fn persist_cookies_after_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn send_setting_event<T>(
|
||||
event_tx: &mpsc::Sender<SenderHttpResponseEvent>,
|
||||
name: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
setting: &ResolvedSetting<T>,
|
||||
) {
|
||||
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
source_model: Some(setting.source_model.clone()),
|
||||
source_id: setting.source_id.clone(),
|
||||
source_name: setting.source_name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
fn timeout_setting_value(timeout: Option<Duration>) -> String {
|
||||
match timeout {
|
||||
Some(timeout) if !timeout.is_zero() => format!("{timeout:?}"),
|
||||
_ => "Infinity".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_setting_from_settings(proxy: Option<ProxySetting>) -> HttpConnectionProxySetting {
|
||||
match proxy {
|
||||
None => HttpConnectionProxySetting::System,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user