diff --git a/crates-server/yaak-server/src/http.rs b/crates-server/yaak-server/src/http.rs index 865cce12..2c1d51ab 100644 --- a/crates-server/yaak-server/src/http.rs +++ b/crates-server/yaak-server/src/http.rs @@ -247,12 +247,12 @@ async fn response_body( Query(_q): Query, headers: HeaderMap, ) -> Response { - let response = match app.state.db().get_http_response(&id) { - Ok(response) => response, + let location = match app.state.locate_response_body(&id) { + Ok(location) => location, Err(_) => return (StatusCode::NOT_FOUND, "No such response").into_response(), }; - let Some(body_path) = response.body_path else { + let Some(body_path) = location.path else { return (StatusCode::NOT_FOUND, "Response has no body").into_response(); }; @@ -269,12 +269,11 @@ async fn response_body( } }; - let content_type = response - .headers - .iter() - .find(|h| h.name.eq_ignore_ascii_case("content-type")) - .map(|h| h.value.clone()) - .unwrap_or_else(|| "application/octet-stream".to_string()); + let content_type = if location.content_type.is_empty() { + "application/octet-stream".to_string() + } else { + location.content_type + }; let range = headers.get(header::RANGE).and_then(|v| v.to_str().ok()).and_then(parse_range); diff --git a/crates-server/yaak-server/src/rpc/commands.rs b/crates-server/yaak-server/src/rpc/commands.rs index f0e60a21..ecf960b4 100644 --- a/crates-server/yaak-server/src/rpc/commands.rs +++ b/crates-server/yaak-server/src/rpc/commands.rs @@ -490,27 +490,22 @@ async fn cmd_send_ephemeral_request( #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CmdHttpResponseBodyReq { - pub response: HttpResponse, + pub response_id: String, pub filter: Option, } +/// The frontend hands back an id and never a path, so the only bodies reachable +/// here are ones the engine wrote and the database still knows about. async fn cmd_http_response_body( ctx: BridgeCtx, req: CmdHttpResponseBodyReq, ) -> Result { - let Some(body_path) = req.response.body_path else { + let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?; + let Some(body_path) = location.path else { return Ok(FilterResponse { content: String::new(), error: None }); }; - let content_type = req - .response - .headers - .iter() - .find_map(|h| { - if h.name.eq_ignore_ascii_case("content-type") { Some(h.value.as_str()) } else { None } - }) - .unwrap_or_default(); - + let content_type = location.content_type.as_str(); let body = read_response_body(&body_path, content_type) .await .ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?; @@ -525,6 +520,24 @@ async fn cmd_http_response_body( } } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CmdHttpResponseBodyPathReq { + pub response_id: String, +} + +/// The desktop host uses this to open the file itself. A tab cannot open a +/// path, so the bridge's browser host never calls it — it fetches +/// `/responses/:id/body` instead — but the command answers honestly for any +/// client that does, with the path on the bridge's machine. +async fn cmd_http_response_body_path( + ctx: BridgeCtx, + req: CmdHttpResponseBodyPathReq, +) -> Result> { + let location = ctx.state.locate_response_body(&req.response_id).map_err(err)?; + Ok(location.path.map(|p| p.to_string_lossy().to_string())) +} + /// Decode a response body from disk using the charset its Content-Type /// declares. Ported from crates-tauri/yaak-app-client/src/encoding.rs. async fn read_response_body(body_path: impl AsRef, content_type: &str) -> Option { @@ -576,16 +589,21 @@ async fn cmd_get_http_response_events( #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CmdGetSseEventsReq { - pub file_path: String, + pub response_id: String, } async fn cmd_get_sse_events( - _ctx: BridgeCtx, + ctx: BridgeCtx, req: CmdGetSseEventsReq, ) -> Result> { use eventsource_client::{EventParser, SSE}; - let body = std::fs::read(&req.file_path).map_err(err)?; + let Some(body_path) = ctx.state.locate_response_body(&req.response_id).map_err(err)?.path + else { + return Ok(Vec::new()); + }; + + let body = std::fs::read(&body_path).map_err(err)?; let mut event_parser = EventParser::new(); event_parser.process_bytes(body).map_err(err)?; @@ -1107,6 +1125,7 @@ rpc_commands! { cmd_send_ephemeral_request, cmd_http_response_body, + cmd_http_response_body_path, cmd_http_request_body, cmd_get_http_response_events, cmd_get_sse_events, diff --git a/crates-server/yaak-server/src/state.rs b/crates-server/yaak-server/src/state.rs index c216ae48..06d755a1 100644 --- a/crates-server/yaak-server/src/state.rs +++ b/crates-server/yaak-server/src/state.rs @@ -84,6 +84,14 @@ impl BridgeCapabilities { } } +/// Where a response's body is, and what it is meant to be read as. +pub struct ResponseBodyLocation { + /// None when the response has no stored body. + pub path: Option, + /// The response's declared `Content-Type`, empty when it has none. + pub content_type: String, +} + pub struct BridgeState { data_dir: PathBuf, query_manager: QueryManager, @@ -199,6 +207,28 @@ impl BridgeState { self.data_dir.join("responses") } + /// Find a response's body from its id alone. + /// + /// The tab hands back an id and never a path, so the only bodies reachable + /// through the bridge are ones the engine wrote and the database still + /// knows about. Every route and command that reads a body goes through + /// here for that reason. + pub fn locate_response_body( + &self, + response_id: &str, + ) -> yaak_models::error::Result { + let response = self.db().get_http_response(response_id)?; + Ok(ResponseBodyLocation { + path: response.body_path.map(PathBuf::from), + content_type: response + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("content-type")) + .map(|h| h.value.clone()) + .unwrap_or_default(), + }) + } + pub fn db(&self) -> ClientDb<'_> { self.query_manager.connect() } diff --git a/packages/platform/src/bridge/index.ts b/packages/platform/src/bridge/index.ts index 70f49be2..5b75a811 100644 --- a/packages/platform/src/bridge/index.ts +++ b/packages/platform/src/bridge/index.ts @@ -62,17 +62,6 @@ function detectOsType(): OsType { return "linux"; } -/** - * A response body path is an opaque handle the backend minted, and the bridge - * writes them as `/responses/`. Taking the last segment - * turns it back into the id the `/responses/{id}/body` route wants, which keeps - * the server from ever being asked for a path chosen by the page. - */ -function responseIdFromBodyPath(path: string): string { - const segments = path.split(/[/\\]/); - return segments[segments.length - 1] ?? path; -} - /** * Answer the Tauri host-plugin commands, which ride outside the RPC envelope. * @@ -221,25 +210,34 @@ export function createBridgePlatform(baseUrl: string, token: string | null): Pla }, files: { - async readFile(path) { - const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`); + readDir: async () => { + throw unsupported("Browsing the filesystem"); + }, + // No filesystem here, so a path is just a string this host echoes back. + url: (path) => path, + basename: async (path) => path.split(/[/\\]/).pop() ?? path, + resolveResource: async (path) => path, + }, + + // Bodies live on the bridge's disk and are addressed by response id. The + // server resolves the id through its database, so a page can only ever + // reach a body the engine actually wrote. + blobs: { + async read(id) { + const res = await connection.fetch(`/responses/${encodeURIComponent(id)}/body`); + if (res.status === 404) return null; if (!res.ok) { throw new Error(`Failed to read response body (${res.status})`); } return new Uint8Array(await res.arrayBuffer()); }, - readDir: async () => { - throw unsupported("Browsing the filesystem"); - }, - // The ``/`