mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-26 13:24:48 +02:00
Read bodies of responses the engine never recorded
A send with no request behind it — a plugin's ad-hoc ctx.httpRequest.send, GraphQL introspection — gets a generated id and a body file, but no row. Resolving purely through the database refused those, which would have broken auth-oauth2 the moment it moved off readFileSync, since every request it sends is ad-hoc. The store now falls back to the response directory when there is no row, accepting only ids shaped the way the engine generates them. Such a response has no stored headers, so contentType is null and text() decodes as UTF-8 — which is what the filesystem readers did anyway.
This commit is contained in:
@@ -132,7 +132,7 @@ async fn build_plugin_reply(
|
|||||||
|
|
||||||
match handle_shared_plugin_event(
|
match handle_shared_plugin_event(
|
||||||
&host_context.query_manager,
|
&host_context.query_manager,
|
||||||
&FileResponseBodyStore::new(&host_context.query_manager),
|
&FileResponseBodyStore::new(&host_context.query_manager, &host_context.response_dir),
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
SharedPluginEventContext { plugin_name, workspace_id: shared_workspace_id },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -53,9 +53,13 @@ pub(crate) async fn handle_plugin_event<R: Runtime>(
|
|||||||
.and_then(|window| workspace_from_window(&window).map(|workspace| workspace.id))
|
.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(
|
match handle_shared_plugin_event(
|
||||||
app_handle.db_manager().inner(),
|
app_handle.db_manager().inner(),
|
||||||
&FileResponseBodyStore::new(app_handle.db_manager().inner()),
|
&FileResponseBodyStore::new(app_handle.db_manager().inner(), &response_dir),
|
||||||
&event.payload,
|
&event.payload,
|
||||||
SharedPluginEventContext {
|
SharedPluginEventContext {
|
||||||
plugin_name: &plugin_name,
|
plugin_name: &plugin_name,
|
||||||
|
|||||||
@@ -481,6 +481,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
|
use crate::response_body::{FileResponseBodyStore, ResponseBodyInfo};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
|
use std::path::Path;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
use yaak_models::models::{AnyModel, Folder, HttpRequest, Workspace};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
@@ -493,7 +494,7 @@ mod tests {
|
|||||||
) -> GroupedPluginEvent<'a> {
|
) -> GroupedPluginEvent<'a> {
|
||||||
handle_shared_plugin_event(
|
handle_shared_plugin_event(
|
||||||
query_manager,
|
query_manager,
|
||||||
&FileResponseBodyStore::new(query_manager),
|
&FileResponseBodyStore::new(query_manager, Path::new("/nonexistent-response-dir")),
|
||||||
payload,
|
payload,
|
||||||
context,
|
context,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read, Seek, SeekFrom};
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use yaak_models::query_manager::QueryManager;
|
use yaak_models::query_manager::QueryManager;
|
||||||
|
|
||||||
/// The most bytes one read will hand back, however much was asked for.
|
/// The most bytes one read will hand back, however much was asked for.
|
||||||
@@ -43,34 +44,60 @@ pub trait ResponseBodyStore {
|
|||||||
/// filesystem holds it.
|
/// filesystem holds it.
|
||||||
pub struct FileResponseBodyStore<'a> {
|
pub struct FileResponseBodyStore<'a> {
|
||||||
query_manager: &'a QueryManager,
|
query_manager: &'a QueryManager,
|
||||||
|
response_dir: &'a Path,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> FileResponseBodyStore<'a> {
|
impl<'a> FileResponseBodyStore<'a> {
|
||||||
pub fn new(query_manager: &'a QueryManager) -> Self {
|
pub fn new(query_manager: &'a QueryManager, response_dir: &'a Path) -> Self {
|
||||||
Self { query_manager }
|
Self { query_manager, response_dir }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The file backing a response, or `None` when the response stored no body.
|
/// The file backing a response, or `None` when it stored no body.
|
||||||
///
|
///
|
||||||
/// A response that was never persisted (GraphQL introspection and other
|
/// Most responses have a row that names their file. A response sent with no
|
||||||
/// ephemeral sends) has no row here at all, so it fails as "not found"
|
/// request behind it — the plugin `ctx.httpRequest.send` of an ad-hoc
|
||||||
/// rather than reading as an empty body.
|
/// request, GraphQL introspection — is ephemeral: the engine gives it an id
|
||||||
fn body_path(&self, response_id: &str) -> Result<Option<String>> {
|
/// and writes its body under that id, but never records it. Those bodies are
|
||||||
Ok(self.query_manager.connect().get_http_response(response_id)?.body_path)
|
/// still the caller's to read, so fall back to where the engine puts them.
|
||||||
|
fn body_path(&self, response_id: &str) -> Result<Option<PathBuf>> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResponseBodyStore for FileResponseBodyStore<'_> {
|
impl ResponseBodyStore for FileResponseBodyStore<'_> {
|
||||||
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
|
fn info(&self, response_id: &str) -> Result<ResponseBodyInfo> {
|
||||||
let response = self.query_manager.connect().get_http_response(response_id)?;
|
// 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 content_type = response
|
let content_length = match self.body_path(response_id)? {
|
||||||
.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(),
|
Some(path) => std::fs::metadata(path)?.len(),
|
||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
@@ -105,6 +132,10 @@ mod tests {
|
|||||||
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
|
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseHeader, Workspace};
|
||||||
use yaak_models::util::UpdateSource;
|
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) {
|
fn seed(body: Option<&[u8]>) -> (QueryManager, TempDir, String) {
|
||||||
let temp_dir = TempDir::new().unwrap();
|
let temp_dir = TempDir::new().unwrap();
|
||||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||||
@@ -165,7 +196,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn info_reports_stored_size_and_content_type() {
|
fn info_reports_stored_size_and_content_type() {
|
||||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||||
let info = FileResponseBodyStore::new(&qm).info(&id).unwrap();
|
let info = store(&qm, &_tmp).info(&id).unwrap();
|
||||||
assert_eq!(info.content_length, 11);
|
assert_eq!(info.content_length, 11);
|
||||||
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
|
assert_eq!(info.content_type.as_deref(), Some("application/json; charset=utf-8"));
|
||||||
}
|
}
|
||||||
@@ -173,7 +204,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn chunks_cover_the_body_and_stop_short_at_the_end() {
|
fn chunks_cover_the_body_and_stop_short_at_the_end() {
|
||||||
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
let (qm, _tmp, id) = seed(Some(b"hello world"));
|
||||||
let store = FileResponseBodyStore::new(&qm);
|
let store = store(&qm, &_tmp);
|
||||||
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
|
assert_eq!(store.read_chunk(&id, 0, 5).unwrap(), b"hello");
|
||||||
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
|
assert_eq!(store.read_chunk(&id, 6, 100).unwrap(), b"world");
|
||||||
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
|
assert!(store.read_chunk(&id, 11, 100).unwrap().is_empty());
|
||||||
@@ -184,7 +215,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn a_response_with_no_body_is_empty_not_an_error() {
|
fn a_response_with_no_body_is_empty_not_an_error() {
|
||||||
let (qm, _tmp, id) = seed(None);
|
let (qm, _tmp, id) = seed(None);
|
||||||
let store = FileResponseBodyStore::new(&qm);
|
let store = store(&qm, &_tmp);
|
||||||
assert_eq!(store.info(&id).unwrap().content_length, 0);
|
assert_eq!(store.info(&id).unwrap().content_length, 0);
|
||||||
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
|
assert!(store.read_chunk(&id, 0, 100).unwrap().is_empty());
|
||||||
}
|
}
|
||||||
@@ -192,6 +223,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_unknown_response_fails() {
|
fn an_unknown_response_fails() {
|
||||||
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
let (qm, _tmp, _id) = seed(Some(b"hi"));
|
||||||
assert!(FileResponseBodyStore::new(&qm).info("rs_nope").is_err());
|
assert!(store(&qm, &_tmp).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.
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -261,11 +261,14 @@ export const plugin: PluginDefinition = {
|
|||||||
* The response's body as text, or null when there is nothing to read.
|
* The response's body as text, or null when there is nothing to read.
|
||||||
*
|
*
|
||||||
* The host is asked for it by response id, so this works wherever the bytes
|
* The host is asked for it by response id, so this works wherever the bytes
|
||||||
* happen to live. A body over the runtime's size limit throws rather than
|
* happen to live — including responses it never recorded, which still get an
|
||||||
* coming back empty, since a template silently rendering to nothing is worse
|
* id. A body over the runtime's size limit throws rather than coming back
|
||||||
* than one that says why.
|
* empty, since a template silently rendering to nothing is worse than one that
|
||||||
|
* says why.
|
||||||
*/
|
*/
|
||||||
async function readResponseBody(ctx: Context, response: HttpResponse): Promise<string | null> {
|
async function readResponseBody(ctx: Context, response: HttpResponse): Promise<string | null> {
|
||||||
|
// Belt and braces: everything reaching here came from find() or send() and so
|
||||||
|
// has an id. An empty one would just be an unreadable id.
|
||||||
if (!response.id) return null;
|
if (!response.id) return null;
|
||||||
|
|
||||||
const body = await ctx.httpResponse.body({ responseId: response.id });
|
const body = await ctx.httpResponse.body({ responseId: response.id });
|
||||||
|
|||||||
Reference in New Issue
Block a user