mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 04:44:12 +02:00
Add the browser send proxy and web sender
crates-server/yaak-send-proxy: a stateless executor over yaak-http's HttpTransaction. It takes a rendered request, streams timeline events, the response head, body chunks and the resulting cookies back as NDJSON, and keeps nothing. Private/loopback/link-local/metadata ranges are refused after DNS on every hop (an AddressFilter on the resolver plus a per-hop URL check), with size caps, a timeout ceiling, a rate limit, host allow/deny lists and an optional token. The web host now sends through it: the wasm worker resolves and renders the request (render_http_request moved into yaak-models so it builds for wasm; re-exported from its old paths), the tab posts it, and stores what comes back where the desktop stores it. Requests needing auth plugins or template functions are refused with the reason until plugins run in the browser.
This commit is contained in:
+229
-3
@@ -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,18 @@ 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::models::{
|
||||
AnyModel, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
};
|
||||
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.
|
||||
@@ -201,6 +209,20 @@ struct UpsertIntrospectionReq {
|
||||
content: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ResponseIdReq {
|
||||
response_id: String,
|
||||
}
|
||||
|
||||
#[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 +327,34 @@ 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 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();
|
||||
@@ -316,6 +366,182 @@ fn dispatch(
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Preparing a send */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PrepareHttpSendReq {
|
||||
request_id: String,
|
||||
environment_id: Option<String>,
|
||||
cookie_jar_id: Option<String>,
|
||||
}
|
||||
|
||||
/// The values the proxy needs to obey. Where each came from is in `setting_events`.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PreparedSendSettings {
|
||||
validate_certificates: bool,
|
||||
follow_redirects: bool,
|
||||
timeout_ms: i64,
|
||||
send_cookies: bool,
|
||||
store_cookies: bool,
|
||||
}
|
||||
|
||||
/// 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: PreparedSendSettings,
|
||||
/// 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 send proxy.
|
||||
///
|
||||
/// 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 setting_events = setting_events(&settings);
|
||||
let prepared = PreparedHttpSend {
|
||||
request: rendered,
|
||||
settings: PreparedSendSettings {
|
||||
validate_certificates: settings.validate_certificates.value,
|
||||
follow_redirects: settings.follow_redirects.value,
|
||||
timeout_ms: settings.request_timeout.value as i64,
|
||||
send_cookies: settings.send_cookies.value,
|
||||
store_cookies: settings.store_cookies.value,
|
||||
},
|
||||
setting_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)
|
||||
}
|
||||
|
||||
/// The same five `Setting` lines `crates/yaak/src/send.rs` writes at the top of every send.
|
||||
fn setting_events(settings: &ResolvedHttpRequestSettings) -> Vec<HttpResponseEventData> {
|
||||
fn event<T: ToString>(
|
||||
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 settings.request_timeout.value > 0 {
|
||||
format!("{}ms", settings.request_timeout.value)
|
||||
} else {
|
||||
"Infinity".to_string()
|
||||
};
|
||||
vec![
|
||||
event(
|
||||
"validate_certificates",
|
||||
settings.validate_certificates.value.to_string(),
|
||||
&settings.validate_certificates,
|
||||
),
|
||||
event("redirects", settings.follow_redirects.value.to_string(), &settings.follow_redirects),
|
||||
event("timeout", timeout, &settings.request_timeout),
|
||||
event("send_cookies", settings.send_cookies.value.to_string(), &settings.send_cookies),
|
||||
event("store_cookies", settings.store_cookies.value.to_string(), &settings.store_cookies),
|
||||
]
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Blobs */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user