diff --git a/crates-cli/yaak-cli/src/plugin_events.rs b/crates-cli/yaak-cli/src/plugin_events.rs index a56f186b..146c1025 100644 --- a/crates-cli/yaak-cli/src/plugin_events.rs +++ b/crates-cli/yaak-cli/src/plugin_events.rs @@ -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; @@ -132,7 +134,7 @@ async fn build_plugin_reply( match handle_shared_plugin_event( &host_context.query_manager, - &FileResponseBodyStore::new(&host_context.query_manager, &host_context.response_dir), + &FileResponseBodyStore::new(&host_context.query_manager), &event.payload, SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id }, ) { @@ -225,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}"), diff --git a/crates-tauri/yaak-app-client/src/plugin_events.rs b/crates-tauri/yaak-app-client/src/plugin_events.rs index 2bd1ee56..3049a2a2 100644 --- a/crates-tauri/yaak-app-client/src/plugin_events.rs +++ b/crates-tauri/yaak-app-client/src/plugin_events.rs @@ -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; @@ -53,13 +55,9 @@ pub(crate) async fn handle_plugin_event( .and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id)) }); - // Same directory the engine writes bodies into, so responses it never - // recorded are still readable by id. - let response_dir = app_handle.path().app_data_dir()?.join("responses"); - match handle_shared_plugin_event( app_handle.db_manager().inner(), - &FileResponseBodyStore::new(app_handle.db_manager().inner(), &response_dir), + &FileResponseBodyStore::new(app_handle.db_manager().inner()), &event.payload, SharedPluginEventContext { plugin_name: &plugin_name, @@ -319,8 +317,13 @@ async fn handle_host_plugin_request( ) .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) => { diff --git a/crates/yaak-plugins/bindings/gen_events.ts b/crates/yaak-plugins/bindings/gen_events.ts index 3b25831a..e23874e2 100644 --- a/crates/yaak-plugins/bindings/gen_events.ts +++ b/crates/yaak-plugins/bindings/gen_events.ts @@ -557,7 +557,16 @@ export type RenderPurpose = "send" | "preview"; export type SendHttpRequestRequest = { httpRequest: Partial, }; -export type SendHttpRequestResponse = { httpResponse: HttpResponse, }; +export type SendHttpRequestResponse = { httpResponse: 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`. + */ +body?: string | null, }; export type SetKeyValueRequest = { key: string, value: string, }; diff --git a/crates/yaak-plugins/src/events.rs b/crates/yaak-plugins/src/events.rs index 5faeb3c0..cd981d93 100644 --- a/crates/yaak-plugins/src/events.rs +++ b/crates/yaak-plugins/src/events.rs @@ -294,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, } #[derive(Debug, Clone, Default, Serialize, Deserialize, TS)] diff --git a/crates/yaak/src/plugin_events.rs b/crates/yaak/src/plugin_events.rs index 2930cdb2..ef367ada 100644 --- a/crates/yaak/src/plugin_events.rs +++ b/crates/yaak/src/plugin_events.rs @@ -481,7 +481,6 @@ mod tests { use super::*; use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo}; use std::cell::RefCell; - use std::path::Path; use tempfile::TempDir; use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace}; use yaak_models::util::UpdateSource; @@ -494,7 +493,7 @@ mod tests { ) -> GroupedPluginEvent<'a> { handle_shared_plugin_event( query_manager, - &FileResponseBodyStore::new(query_manager, Path::new("/nonexistent-response-dir")), + &FileResponseBodyStore::new(query_manager), payload, context, ) diff --git a/crates/yaak/src/response_body.rs b/crates/yaak/src/response_body.rs index c6aa83a2..1622c54d 100644 --- a/crates/yaak/src/response_body.rs +++ b/crates/yaak/src/response_body.rs @@ -4,11 +4,13 @@ //! engine wrote under `/responses/` 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 std::path::{Path, PathBuf}; use yaak_models::query_manager::QueryManager; /// The most bytes one read will hand back, however much was asked for. @@ -44,60 +46,34 @@ pub trait ResponseBodyStore { /// filesystem holds it. pub struct FileResponseBodyStore<'a> { query_manager: &'a QueryManager, - response_dir: &'a Path, } impl<'a> FileResponseBodyStore<'a> { - pub fn new(query_manager: &'a QueryManager, response_dir: &'a Path) -> Self { - Self { query_manager, response_dir } + pub fn new(query_manager: &'a QueryManager) -> Self { + Self { query_manager } } /// The file backing a response, or `None` when it stored no body. /// - /// Most responses have a row that names their file. A response sent with no - /// request behind it — the plugin `ctx.httpRequest.send` of an ad-hoc - /// request, GraphQL introspection — is ephemeral: the engine gives it an id - /// and writes its body under that id, but never records it. Those bodies are - /// still the caller's to read, so fall back to where the engine puts them. - fn body_path(&self, response_id: &str) -> Result> { - match self.query_manager.connect().get_http_response(response_id) { - Ok(response) => Ok(response.body_path.map(PathBuf::from)), - Err(err) => match self.ephemeral_path(response_id) { - Some(path) if path.is_file() => Ok(Some(path)), - _ => Err(err.into()), - }, - } - } - - /// Where an unrecorded response's body would be, if the id can name one. - /// - /// Ids arrive from a plugin, so only the shape the engine actually generates - /// is accepted; that leaves nothing that could climb out of the response - /// directory. - fn ephemeral_path(&self, response_id: &str) -> Option { - let looks_generated = response_id.starts_with("rs_") - && response_id.len() > 3 - && response_id[3..].chars().all(|c| c.is_ascii_alphanumeric()); - - looks_generated.then(|| self.response_dir.join(response_id)) + /// 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> { + Ok(self.query_manager.connect().get_http_response(response_id)?.body_path) } } impl ResponseBodyStore for FileResponseBodyStore<'_> { fn info(&self, response_id: &str) -> Result { - // The headers live on the row, so an ephemeral response has none to - // give. Readers that need its charset have the response object the send - // handed back. - let content_type = match self.query_manager.connect().get_http_response(response_id) { - Ok(response) => response - .headers - .iter() - .find(|h| h.name.eq_ignore_ascii_case("content-type")) - .map(|h| h.value.clone()), - Err(_) => None, - }; + let response = self.query_manager.connect().get_http_response(response_id)?; - let content_length = match self.body_path(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, }; @@ -132,10 +108,6 @@ mod tests { use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace}; use yaak_models::util::UpdateSource; - fn store<'a>(qm: &'a QueryManager, dir: &'a TempDir) -> FileResponseBodyStore<'a> { - FileResponseBodyStore::new(qm, dir.path()) - } - fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) { let temp_dir = TempDir::new().unwrap(); let (query_manager, blob_manager, _rx) = yaak_models::init_standalone( @@ -196,7 +168,7 @@ mod tests { #[test] fn info_reports_stored_size_and_content_type() { let (qm, _tmp, id) = seed(Some(b"hello world")); - let info = store(&qm, &_tmp).info(&id).unwrap(); + 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")); } @@ -204,7 +176,7 @@ mod tests { #[test] fn chunks_cover_the_body_and_stop_short_at_the_end() { let (qm, _tmp, id) = seed(Some(b"hello world")); - let store = store(&qm, &_tmp); + 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()); @@ -215,7 +187,7 @@ mod tests { #[test] fn a_response_with_no_body_is_empty_not_an_error() { let (qm, _tmp, id) = seed(None); - let store = store(&qm, &_tmp); + let store = FileResponseBodyStore::new(&qm); assert_eq!(store.info(&id).unwrap().content_length, 0); assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty()); } @@ -223,30 +195,16 @@ mod tests { #[test] fn an_unknown_response_fails() { let (qm, _tmp, _id) = seed(Some(b"hi")); - assert!(store(&qm, &_tmp).info("rs_nope").is_err()); + assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err()); } #[test] - fn an_ephemeral_body_is_readable_by_id_without_a_row() { - // What a plugin's ad-hoc `ctx.httpRequest.send` leaves behind: a file - // named for a response the engine never recorded. + 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(); - let info = store(&qm, &tmp).info("rs_ephemeral1").unwrap(); - assert_eq!(info.content_length, 16); - // No row means no headers to report a charset from. - assert_eq!(info.content_type, None); - assert_eq!(store(&qm, &tmp).read_chunk("rs_ephemeral1", 0, 6).unwrap(), b"access"); - } - - #[test] - fn an_id_that_is_not_a_generated_one_cannot_name_a_file() { - let (qm, tmp, _id) = seed(Some(b"hi")); - std::fs::write(tmp.path().join("secrets"), b"nope").unwrap(); - - for id in ["secrets", "../secrets", "rs_../secrets", "rs_", "/etc/hosts"] { - assert!(store(&qm, &tmp).info(id).is_err(), "{id} should not resolve"); - } + assert!(FileResponseBodyStore::new(&qm).info("rs_ephemeral1").is_err()); } } diff --git a/crates/yaak/src/send.rs b/crates/yaak/src/send.rs index 3b70c4df..bcd61386 100644 --- a/crates/yaak/src/send.rs +++ b/crates/yaak/src/send.rs @@ -354,6 +354,19 @@ pub enum ResponseBody { Returned(Vec), } +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, diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 3b25831a..e23874e2 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -557,7 +557,16 @@ export type RenderPurpose = "send" | "preview"; export type SendHttpRequestRequest = { httpRequest: Partial, }; -export type SendHttpRequestResponse = { httpResponse: HttpResponse, }; +export type SendHttpRequestResponse = { httpResponse: 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`. + */ +body?: string | null, }; export type SetKeyValueRequest = { key: string, value: string, }; diff --git a/packages/plugin-runtime-types/src/plugins/Context.ts b/packages/plugin-runtime-types/src/plugins/Context.ts index 7de049e2..31197b82 100644 --- a/packages/plugin-runtime-types/src/plugins/Context.ts +++ b/packages/plugin-runtime-types/src/plugins/Context.ts @@ -184,6 +184,11 @@ export interface Context { * Read a response's body by id. Where the host keeps the bytes — files on * a desktop, rows in a database, somewhere else later — is not something a * plugin sees or should depend on. + * + * Works for any response the host saved, and for one that `send` returned + * without saving — those are held for the rest of the call that sent them, + * since nothing else has a copy. Reaching for an unsaved response's id in a + * later call throws, because by then its bytes are gone. */ body(args: GetHttpResponseBodyInfoRequest): Promise; }; diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index ea6ad752..a38e49d0 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -609,6 +609,14 @@ export class PluginInstance { } #newCtx(context: PluginContext): Context { + // Bodies of sends that saved nothing, keyed by the response id they were + // handed back with. + // + // A ctx is built per incoming call, so these last exactly as long as the + // plugin invocation that produced them — which is the whole life of an + // unsaved response. Nothing else can reach one: the host has no row for it. + const unsavedBodies = new Map(); + const _windowInfo = async () => { if (context.label == null) { throw new Error("Can't get window context without an active window"); @@ -763,6 +771,18 @@ export class PluginInstance { return httpResponses; }, body: async ({ responseId }) => { + const unsaved = unsavedBodies.get(responseId); + if (unsaved != null) { + return createResponseBody( + { + responseId, + contentLength: unsaved.bytes.byteLength, + contentType: unsaved.contentType, + }, + async (offset, length) => unsaved.bytes.slice(offset, offset + length), + ); + } + const info = await this.#sendForReply( context, { type: "get_http_response_body_info_request", responseId }, @@ -816,10 +836,23 @@ export class PluginInstance { type: "send_http_request_request", ...args, } as const; - const { httpResponse } = await this.#sendForReply( + const { httpResponse, body } = await this.#sendForReply( context, payload, ); + + // A send with no request behind it saves nothing, so this reply is + // the only copy of its body. Hold it so ctx.httpResponse.body() can + // answer for it the same way it answers for a saved response. + if (body != null) { + unsavedBodies.set(httpResponse.id, { + bytes: decodeBase64Chunk(body), + contentType: + httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ?? + null, + }); + } + return httpResponse; }, render: async (args) => {