mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 12:54:09 +02:00
Address response bodies by response id instead of a filesystem path
The body commands took a path from the client, so a token holder could read any file the process could. They now take a response id and resolve the location themselves, and the UI never sees a path at all: the desktop host asks the backend where the file is, and the bridge fetches /responses/:id/body. Ephemeral responses (GraphQL introspection) never reach the database, so resolution falls back to the path send writes them to.
This commit is contained in:
@@ -8,6 +8,7 @@ import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
|
|||||||
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
|
||||||
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||||
|
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||||
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
|
|||||||
Component,
|
Component,
|
||||||
}: {
|
}: {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
Component: ComponentType<{ bodyPath: string }>;
|
Component: ComponentType<{ url: string }>;
|
||||||
}) {
|
}) {
|
||||||
if (response.bodyPath === null) {
|
// Wait until the response has been fully-downloaded before asking for it
|
||||||
return <div>Empty response body</div>;
|
const complete = response.state === "closed";
|
||||||
}
|
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||||
|
|
||||||
// Wait until the response has been fully-downloaded
|
if (!complete || bodyUrl.isPending) {
|
||||||
if (response.state !== "closed") {
|
|
||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<LoadingIcon />
|
<LoadingIcon />
|
||||||
@@ -424,7 +424,15 @@ function EnsureCompleteResponse({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Component bodyPath={response.bodyPath} />;
|
if (bodyUrl.error) {
|
||||||
|
return <Banner color="danger">{String(bodyUrl.error)}</Banner>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bodyUrl.data == null) {
|
||||||
|
return <div>Empty response body</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component url={bodyUrl.data} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
|
export function AudioViewer({ url, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (url) {
|
||||||
setSrc(platform.files.url(bodyPath));
|
setSrc(url);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
// The type matters here in a way it doesn't for an image: a media element goes by what
|
// The type matters here in a way it doesn't for an image: a media element goes by what
|
||||||
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||||
const url = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(objectUrl);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <audio className="w-full" controls src={src} />;
|
return <audio className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
type Props = { className?: string; mimeType?: string } & (
|
type Props = { className?: string; mimeType?: string } & (
|
||||||
| {
|
| {
|
||||||
bodyPath: string;
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
data: ArrayBuffer;
|
data: ArrayBuffer;
|
||||||
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
|
|||||||
|
|
||||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
|
const url = "url" in props ? props.url : null;
|
||||||
const data = "data" in props ? props.data : null;
|
const data = "data" in props ? props.data : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath != null) {
|
if (url != null) {
|
||||||
setSrc(platform.files.url(bodyPath));
|
setSrc(url);
|
||||||
} else if (data != null) {
|
} else if (data != null) {
|
||||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||||
const url = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(objectUrl);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useMemo, useRef, useState } from "react";
|
|||||||
import { Document, Page } from "react-pdf";
|
import { Document, Page } from "react-pdf";
|
||||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||||
import { fireAndForget } from "../../lib/fireAndForget";
|
import { fireAndForget } from "../../lib/fireAndForget";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
fireAndForget(
|
fireAndForget(
|
||||||
import("react-pdf").then(({ pdfjs }) => {
|
import("react-pdf").then(({ pdfjs }) => {
|
||||||
@@ -18,7 +17,8 @@ fireAndForget(
|
|||||||
);
|
);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ const options = {
|
|||||||
standardFontDataUrl: "/standard_fonts/",
|
standardFontDataUrl: "/standard_fonts/",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PdfViewer({ bodyPath, data }: Props) {
|
export function PdfViewer({ url, data }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [numPages, setNumPages] = useState<number>();
|
const [numPages, setNumPages] = useState<number>();
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ export function PdfViewer({ bodyPath, data }: Props) {
|
|||||||
// During render, not in an effect: an effect leaves the first paint with no file, and
|
// During render, not in an effect: an effect leaves the first paint with no file, and
|
||||||
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||||
const src = useMemo(() => {
|
const src = useMemo(() => {
|
||||||
if (bodyPath) {
|
if (url) {
|
||||||
return platform.files.url(bodyPath);
|
return url;
|
||||||
}
|
}
|
||||||
if (data) {
|
if (data) {
|
||||||
// Create a copy to avoid "Buffer is already detached" errors
|
// Create a copy to avoid "Buffer is already detached" errors
|
||||||
@@ -45,7 +45,7 @@ export function PdfViewer({ bodyPath, data }: Props) {
|
|||||||
return { data: new Uint8Array(data) };
|
return { data: new Uint8Array(data) };
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [bodyPath, data]);
|
}, [url, data]);
|
||||||
|
|
||||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||||
setNumPages(nextNumPages);
|
setNumPages(nextNumPages);
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
bodyPath?: string;
|
/** A URL the host resolved, for a body it already stored. */
|
||||||
|
url?: string;
|
||||||
data?: Uint8Array;
|
data?: Uint8Array;
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
|
export function VideoViewer({ url, data, mimeType }: Props) {
|
||||||
const [src, setSrc] = useState<string>();
|
const [src, setSrc] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (bodyPath) {
|
if (url) {
|
||||||
setSrc(platform.files.url(bodyPath));
|
setSrc(url);
|
||||||
} else if (data) {
|
} else if (data) {
|
||||||
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
// As in AudioViewer: a media element trusts the declared type instead of sniffing
|
||||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
|
||||||
const url = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
setSrc(url);
|
setSrc(objectUrl);
|
||||||
return () => URL.revokeObjectURL(url);
|
return () => URL.revokeObjectURL(objectUrl);
|
||||||
} else {
|
} else {
|
||||||
setSrc(undefined);
|
setSrc(undefined);
|
||||||
}
|
}
|
||||||
}, [bodyPath, data, mimeType]);
|
}, [url, data, mimeType]);
|
||||||
|
|
||||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||||
return <video className="w-full" controls src={src} />;
|
return <video className="w-full" controls src={src} />;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL for a stored response body, for the viewers that hand one to an element
|
||||||
|
* instead of reading the bytes themselves.
|
||||||
|
*
|
||||||
|
* Resolved once here rather than inside each viewer: the host may have to ask
|
||||||
|
* the backend where the body is, and a viewer that computes its source during
|
||||||
|
* render (the PDF one, deliberately) needs it settled before it mounts.
|
||||||
|
*
|
||||||
|
* Null data means the response has no stored body.
|
||||||
|
*/
|
||||||
|
export function useResponseBodyUrl(response: HttpResponse | null) {
|
||||||
|
const responseId = response?.id ?? null;
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["response_body_url", responseId, response?.updatedAt ?? ""],
|
||||||
|
enabled: responseId != null,
|
||||||
|
queryFn: () => (responseId == null ? null : platform.files.responseBodyUrl(responseId)),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-in
|
|||||||
import { rpc } from "./rpc";
|
import { rpc } from "./rpc";
|
||||||
import { platform } from "@yaakapp-internal/platform";
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reading a response body means naming the response, never the file it lives
|
||||||
|
* in: the backend resolves an id against its own records, so nothing the UI
|
||||||
|
* says can point a read somewhere else.
|
||||||
|
*/
|
||||||
|
|
||||||
export async function getResponseBodyText({
|
export async function getResponseBodyText({
|
||||||
response,
|
response,
|
||||||
filter,
|
filter,
|
||||||
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
|
|||||||
filter: string | null;
|
filter: string | null;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const result = await rpc<FilterResponse>("cmd_http_response_body", {
|
const result = await rpc<FilterResponse>("cmd_http_response_body", {
|
||||||
response,
|
responseId: response.id,
|
||||||
filter,
|
filter,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
|
|||||||
export async function getResponseBodyEventSource(
|
export async function getResponseBodyEventSource(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
): Promise<ServerSentEvent[]> {
|
): Promise<ServerSentEvent[]> {
|
||||||
if (!response.bodyPath) return [];
|
|
||||||
try {
|
try {
|
||||||
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
|
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||||
filePath: response.bodyPath,
|
responseId: response.id,
|
||||||
});
|
});
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
return events;
|
return events;
|
||||||
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
|
|||||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = await platform.files.readFile(response.bodyPath);
|
const text = await getResponseBodyDecoded(response);
|
||||||
const text = new TextDecoder("utf-8").decode(bytes);
|
if (text == null) return [];
|
||||||
|
|
||||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
||||||
data,
|
data,
|
||||||
eventType: "",
|
eventType: "",
|
||||||
@@ -53,16 +59,19 @@ export async function getResponseBodySseSummary(
|
|||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
resultKeyPath: string,
|
resultKeyPath: string,
|
||||||
): Promise<SseSummary> {
|
): Promise<SseSummary> {
|
||||||
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
|
const text = await getResponseBodyDecoded(response);
|
||||||
|
if (text == null) return { fragmentCount: 0, summary: "" };
|
||||||
|
|
||||||
const bytes = await platform.files.readFile(response.bodyPath);
|
|
||||||
const text = new TextDecoder("utf-8").decode(bytes);
|
|
||||||
return computeSseSummary(text, resultKeyPath);
|
return computeSseSummary(text, resultKeyPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResponseBodyBytes(
|
export async function getResponseBodyBytes(
|
||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||||
if (!response.bodyPath) return null;
|
return platform.files.readResponseBody(response.id);
|
||||||
return platform.files.readFile(response.bodyPath);
|
}
|
||||||
|
|
||||||
|
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
||||||
|
const bytes = await getResponseBodyBytes(response);
|
||||||
|
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ use super::{BridgeCtx, UNSUPPORTED_COMMANDS, unsupported_command};
|
|||||||
use mime_guess::{Mime, mime};
|
use mime_guess::{Mime, mime};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use yaak::import::{ImportDataParams, import_data as import_data_shared};
|
use yaak::import::{ImportDataParams, import_data as import_data_shared};
|
||||||
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
|
use yaak::models_ops::{delete_model, duplicate_model, upsert_model};
|
||||||
|
use yaak::responses::{is_response_id, response_body_path};
|
||||||
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
|
||||||
use yaak_core::WorkspaceContext;
|
use yaak_core::WorkspaceContext;
|
||||||
use yaak_models::models::{
|
use yaak_models::models::{
|
||||||
@@ -487,10 +488,51 @@ async fn cmd_send_ephemeral_request(
|
|||||||
|
|
||||||
// -- Reading responses --
|
// -- Reading responses --
|
||||||
|
|
||||||
|
/// Where a response's body is, and what it is meant to be read as.
|
||||||
|
struct ResponseBodyLocation {
|
||||||
|
/// None when the response has no stored body.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
/// The response's declared `Content-Type`, empty when it has none. An
|
||||||
|
/// ephemeral response has no headers to consult, so its body decodes as
|
||||||
|
/// UTF-8.
|
||||||
|
content_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a response's body from its id alone.
|
||||||
|
///
|
||||||
|
/// The tab hands back an id and never a path, so nothing a page says can point
|
||||||
|
/// this at a file the engine didn't write — the same rule `GET
|
||||||
|
/// /responses/:id/body` follows. Persisted responses carry the path they were
|
||||||
|
/// written to; ephemeral ones (GraphQL introspection) never reach the database,
|
||||||
|
/// but their body still lands at `<response dir>/<id>`, which is how
|
||||||
|
/// [`yaak::send`] builds the path in the first place.
|
||||||
|
fn locate_response_body(ctx: &BridgeCtx, response_id: &str) -> Result<ResponseBodyLocation> {
|
||||||
|
if !is_response_id(response_id) {
|
||||||
|
return Err(RpcError { message: format!("Invalid response ID {response_id}") });
|
||||||
|
}
|
||||||
|
|
||||||
|
match ctx.state.db().get_http_response(response_id) {
|
||||||
|
Ok(response) => 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(),
|
||||||
|
}),
|
||||||
|
Err(yaak_models::error::Error::ModelNotFound(_)) => Ok(ResponseBodyLocation {
|
||||||
|
path: Some(response_body_path(&ctx.state.response_dir(), response_id)),
|
||||||
|
content_type: String::new(),
|
||||||
|
}),
|
||||||
|
Err(e) => Err(err(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CmdHttpResponseBodyReq {
|
pub struct CmdHttpResponseBodyReq {
|
||||||
pub response: HttpResponse,
|
pub response_id: String,
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -498,19 +540,12 @@ async fn cmd_http_response_body(
|
|||||||
ctx: BridgeCtx,
|
ctx: BridgeCtx,
|
||||||
req: CmdHttpResponseBodyReq,
|
req: CmdHttpResponseBodyReq,
|
||||||
) -> Result<FilterResponse> {
|
) -> Result<FilterResponse> {
|
||||||
let Some(body_path) = req.response.body_path else {
|
let location = locate_response_body(&ctx, &req.response_id)?;
|
||||||
|
let Some(body_path) = location.path else {
|
||||||
return Ok(FilterResponse { content: String::new(), error: None });
|
return Ok(FilterResponse { content: String::new(), error: None });
|
||||||
};
|
};
|
||||||
|
|
||||||
let content_type = req
|
let content_type = location.content_type.as_str();
|
||||||
.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 body = read_response_body(&body_path, content_type)
|
let body = read_response_body(&body_path, content_type)
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
|
.ok_or_else(|| RpcError { message: "Failed to find response body".to_string() })?;
|
||||||
@@ -576,16 +611,20 @@ async fn cmd_get_http_response_events(
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct CmdGetSseEventsReq {
|
pub struct CmdGetSseEventsReq {
|
||||||
pub file_path: String,
|
pub response_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get_sse_events(
|
async fn cmd_get_sse_events(
|
||||||
_ctx: BridgeCtx,
|
ctx: BridgeCtx,
|
||||||
req: CmdGetSseEventsReq,
|
req: CmdGetSseEventsReq,
|
||||||
) -> Result<Vec<ServerSentEvent>> {
|
) -> Result<Vec<ServerSentEvent>> {
|
||||||
use eventsource_client::{EventParser, SSE};
|
use eventsource_client::{EventParser, SSE};
|
||||||
|
|
||||||
let body = std::fs::read(&req.file_path).map_err(err)?;
|
let Some(body_path) = locate_response_body(&ctx, &req.response_id)?.path else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = std::fs::read(&body_path).map_err(err)?;
|
||||||
let mut event_parser = EventParser::new();
|
let mut event_parser = EventParser::new();
|
||||||
event_parser.process_bytes(body).map_err(err)?;
|
event_parser.process_bytes(body).map_err(err)?;
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ pub const UNSUPPORTED_COMMANDS: &[&str] = &[
|
|||||||
"cmd_reveal_workspace_key",
|
"cmd_reveal_workspace_key",
|
||||||
"cmd_set_workspace_key",
|
"cmd_set_workspace_key",
|
||||||
// Things that need a local filesystem the tab can point at.
|
// Things that need a local filesystem the tab can point at.
|
||||||
|
//
|
||||||
|
// `cmd_http_response_body_path` is how the desktop host turns a response id
|
||||||
|
// into a file it can open; a tab has nothing to do with the answer and
|
||||||
|
// fetches `/responses/:id/body` instead.
|
||||||
|
"cmd_http_response_body_path",
|
||||||
"cmd_export_data",
|
"cmd_export_data",
|
||||||
"cmd_save_response",
|
"cmd_save_response",
|
||||||
"cmd_save_base64_to_binary",
|
"cmd_save_base64_to_binary",
|
||||||
|
|||||||
+5
-3
File diff suppressed because one or more lines are too long
@@ -30,6 +30,7 @@ use tokio::sync::Mutex;
|
|||||||
use tokio::task::block_in_place;
|
use tokio::task::block_in_place;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
use yaak::export::{self, ExportDataParams};
|
use yaak::export::{self, ExportDataParams};
|
||||||
|
use yaak::responses::{is_response_id, response_body_path};
|
||||||
use yaak_common::command::new_checked_command;
|
use yaak_common::command::new_checked_command;
|
||||||
use yaak_crypto::manager::EncryptionManager;
|
use yaak_crypto::manager::EncryptionManager;
|
||||||
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
use yaak_grpc::manager::{GrpcConfig, GrpcHandle};
|
||||||
@@ -1020,27 +1021,64 @@ async fn cmd_format_graphql(text: &str) -> YaakResult<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a response's body is, and what it is meant to be read as.
|
||||||
|
struct ResponseBodyLocation {
|
||||||
|
/// None when the response has no stored body.
|
||||||
|
path: Option<PathBuf>,
|
||||||
|
/// The response's declared `Content-Type`, empty when it has none. An
|
||||||
|
/// ephemeral response has no headers to consult, so its body decodes as
|
||||||
|
/// UTF-8.
|
||||||
|
content_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a response's body from its id alone.
|
||||||
|
///
|
||||||
|
/// The frontend hands back an id and never a path, so nothing a caller says can
|
||||||
|
/// point this at a file the engine didn't write. Persisted responses carry the
|
||||||
|
/// path they were written to; ephemeral ones (GraphQL introspection) never
|
||||||
|
/// reach the database, but their body still lands at `<response dir>/<id>`,
|
||||||
|
/// which is how [`yaak::send`] builds the path in the first place.
|
||||||
|
fn locate_response_body<R: Runtime>(
|
||||||
|
app_handle: &AppHandle<R>,
|
||||||
|
response_id: &str,
|
||||||
|
) -> YaakResult<ResponseBodyLocation> {
|
||||||
|
if !is_response_id(response_id) {
|
||||||
|
return Err(GenericError(format!("Invalid response ID {response_id}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
match app_handle.db().get_http_response(response_id) {
|
||||||
|
Ok(response) => 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(),
|
||||||
|
}),
|
||||||
|
Err(yaak_models::error::Error::ModelNotFound(_)) => {
|
||||||
|
let response_dir = app_handle.path().app_data_dir()?.join("responses");
|
||||||
|
Ok(ResponseBodyLocation {
|
||||||
|
path: Some(response_body_path(&response_dir, response_id)),
|
||||||
|
content_type: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn cmd_http_response_body<R: Runtime>(
|
async fn cmd_http_response_body<R: Runtime>(
|
||||||
window: WebviewWindow<R>,
|
window: WebviewWindow<R>,
|
||||||
plugin_manager: State<'_, PluginManager>,
|
plugin_manager: State<'_, PluginManager>,
|
||||||
response: HttpResponse,
|
response_id: &str,
|
||||||
filter: Option<&str>,
|
filter: Option<&str>,
|
||||||
) -> YaakResult<FilterResponse> {
|
) -> YaakResult<FilterResponse> {
|
||||||
let body_path = match response.body_path {
|
let location = locate_response_body(window.app_handle(), response_id)?;
|
||||||
None => {
|
let Some(body_path) = location.path else {
|
||||||
return Ok(FilterResponse { content: String::new(), error: None });
|
return Ok(FilterResponse { content: String::new(), error: None });
|
||||||
}
|
|
||||||
Some(p) => p,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let content_type = response
|
let content_type = location.content_type.as_str();
|
||||||
.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 body = read_response_body(&body_path, content_type)
|
let body = read_response_body(&body_path, content_type)
|
||||||
.await
|
.await
|
||||||
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
.ok_or(GenericError("Failed to find response body".to_string()))?;
|
||||||
@@ -1053,6 +1091,20 @@ async fn cmd_http_response_body<R: Runtime>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The body's path on this machine, for the desktop host to read or hand to the
|
||||||
|
/// webview's asset protocol.
|
||||||
|
///
|
||||||
|
/// The frontend holds response ids; only `packages/platform`'s Tauri host sees
|
||||||
|
/// the path, and only because it is about to open the file itself. Hosts
|
||||||
|
/// without a filesystem serve the same bytes over HTTP instead.
|
||||||
|
async fn cmd_http_response_body_path<R: Runtime>(
|
||||||
|
app_handle: AppHandle<R>,
|
||||||
|
response_id: &str,
|
||||||
|
) -> YaakResult<Option<String>> {
|
||||||
|
let location = locate_response_body(&app_handle, response_id)?;
|
||||||
|
Ok(location.path.map(|p| p.to_string_lossy().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
async fn cmd_http_request_body<R: Runtime>(
|
async fn cmd_http_request_body<R: Runtime>(
|
||||||
app_handle: AppHandle<R>,
|
app_handle: AppHandle<R>,
|
||||||
response_id: &str,
|
response_id: &str,
|
||||||
@@ -1069,8 +1121,15 @@ async fn cmd_http_request_body<R: Runtime>(
|
|||||||
Ok(Some(body))
|
Ok(Some(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get_sse_events(file_path: &str) -> YaakResult<Vec<ServerSentEvent>> {
|
async fn cmd_get_sse_events<R: Runtime>(
|
||||||
let body = fs::read(file_path)?;
|
app_handle: AppHandle<R>,
|
||||||
|
response_id: &str,
|
||||||
|
) -> YaakResult<Vec<ServerSentEvent>> {
|
||||||
|
let Some(body_path) = locate_response_body(&app_handle, response_id)?.path else {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = fs::read(body_path)?;
|
||||||
let mut event_parser = EventParser::new();
|
let mut event_parser = EventParser::new();
|
||||||
event_parser.process_bytes(body.into())?;
|
event_parser.process_bytes(body.into())?;
|
||||||
|
|
||||||
|
|||||||
@@ -268,10 +268,17 @@ pub(crate) struct CmdFormatGraphqlReq {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub(crate) struct CmdHttpResponseBodyReq {
|
pub(crate) struct CmdHttpResponseBodyReq {
|
||||||
pub response: HttpResponse,
|
pub response_id: String,
|
||||||
pub filter: Option<String>,
|
pub filter: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, TS)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
|
pub(crate) struct CmdHttpResponseBodyPathReq {
|
||||||
|
pub response_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
@@ -283,7 +290,7 @@ pub(crate) struct CmdHttpRequestBodyReq {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
#[ts(export, export_to = "gen_rpc.ts")]
|
#[ts(export, export_to = "gen_rpc.ts")]
|
||||||
pub(crate) struct CmdGetSseEventsReq {
|
pub(crate) struct CmdGetSseEventsReq {
|
||||||
pub file_path: String,
|
pub response_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, TS)]
|
#[derive(Debug, Deserialize, TS)]
|
||||||
@@ -983,15 +990,19 @@ async fn cmd_format_graphql<R: Runtime>(_ctx: ClientCtx<R>, req: CmdFormatGraphq
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
async fn cmd_http_response_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyReq) -> Result<FilterResponse> {
|
||||||
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), req.response, req.filter.as_deref()).await?)
|
Ok(crate::cmd_http_response_body(ctx.window.clone(), ctx.window.app_handle().state::<PluginManager>(), &req.response_id, req.filter.as_deref()).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cmd_http_response_body_path<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpResponseBodyPathReq) -> Result<Option<String>> {
|
||||||
|
Ok(crate::cmd_http_response_body_path(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
async fn cmd_http_request_body<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestBodyReq) -> Result<Option<Vec<u8>>> {
|
||||||
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
|
Ok(crate::cmd_http_request_body(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get_sse_events<R: Runtime>(_ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
async fn cmd_get_sse_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetSseEventsReq) -> Result<Vec<ServerSentEvent>> {
|
||||||
Ok(crate::cmd_get_sse_events(&req.file_path).await?)
|
Ok(crate::cmd_get_sse_events(ctx.window.app_handle().clone(), &req.response_id).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGetHttpResponseEventsReq) -> Result<Vec<HttpResponseEvent>> {
|
||||||
@@ -1371,6 +1382,7 @@ rpc_commands! {
|
|||||||
cmd_format_json(CmdFormatJsonReq) -> String,
|
cmd_format_json(CmdFormatJsonReq) -> String,
|
||||||
cmd_format_graphql(CmdFormatGraphqlReq) -> String,
|
cmd_format_graphql(CmdFormatGraphqlReq) -> String,
|
||||||
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
|
cmd_http_response_body(CmdHttpResponseBodyReq) -> FilterResponse,
|
||||||
|
cmd_http_response_body_path(CmdHttpResponseBodyPathReq) -> Option<String>,
|
||||||
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
|
||||||
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
|
||||||
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod import;
|
|||||||
pub mod models_ops;
|
pub mod models_ops;
|
||||||
pub mod plugin_events;
|
pub mod plugin_events;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
|
pub mod responses;
|
||||||
pub mod send;
|
pub mod send;
|
||||||
|
|
||||||
pub use error::Error;
|
pub use error::Error;
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
//! Where response bodies live, from a response id alone.
|
||||||
|
//!
|
||||||
|
//! [`send`](crate::send) writes each body to `<response dir>/<response id>`,
|
||||||
|
//! and every reader has to find it again knowing only the id: commands take an
|
||||||
|
//! id from the client and never a path, so a path the client chose can never be
|
||||||
|
//! opened. [`is_response_id`] is the check that makes joining a client-supplied
|
||||||
|
//! id onto the response directory safe.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// The file a response's body is written to, and therefore read back from.
|
||||||
|
pub fn response_body_path(response_dir: &Path, response_id: &str) -> PathBuf {
|
||||||
|
response_dir.join(response_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a string is shaped like a response id, and can therefore be joined
|
||||||
|
/// onto the response directory without escaping it.
|
||||||
|
///
|
||||||
|
/// Ids are `rs_<hex>`. Rejecting anything else outright, rather than trying to
|
||||||
|
/// sanitize it, is what keeps "read the body of response X" from naming a file
|
||||||
|
/// of the caller's choosing: no separators, no `..`, no absolute paths, no
|
||||||
|
/// drive letters, no NUL.
|
||||||
|
pub fn is_response_id(id: &str) -> bool {
|
||||||
|
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_generated_ids() {
|
||||||
|
assert!(is_response_id("rs_A1b2C3d4"));
|
||||||
|
assert!(is_response_id("rs_with-dash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_anything_that_could_escape_the_directory() {
|
||||||
|
for id in [
|
||||||
|
"",
|
||||||
|
"..",
|
||||||
|
"../../etc/passwd",
|
||||||
|
"rs_1/../../secret",
|
||||||
|
"/etc/passwd",
|
||||||
|
r"C:\Windows\System32",
|
||||||
|
r"rs_1\..\secret",
|
||||||
|
"rs_1.txt",
|
||||||
|
"rs_1\0",
|
||||||
|
] {
|
||||||
|
assert!(!is_response_id(id), "{id:?} should be rejected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::render::render_http_request;
|
use crate::render::render_http_request;
|
||||||
|
use crate::responses::response_body_path;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use log::warn;
|
use log::warn;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -660,7 +661,7 @@ pub async fn send_http_request<T: TemplateCallback>(
|
|||||||
source,
|
source,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let body_path = params.response_dir.join(&response.id);
|
let body_path = response_body_path(params.response_dir, &response.id);
|
||||||
let response_body_path = body_path.to_string_lossy().to_string();
|
let response_body_path = body_path.to_string_lossy().to_string();
|
||||||
let connected_response = HttpResponse {
|
let connected_response = HttpResponse {
|
||||||
state: HttpResponseState::Connected,
|
state: HttpResponseState::Connected,
|
||||||
|
|||||||
@@ -63,14 +63,13 @@ function detectOsType(): OsType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A response body path is an opaque handle the backend minted, and the bridge
|
* The route that serves a response body, keyed by the response id.
|
||||||
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
|
*
|
||||||
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
|
* The bridge resolves the id against its own database, so this is the only
|
||||||
* the server from ever being asked for a path chosen by the page.
|
* thing the page ever has to know about where a body lives.
|
||||||
*/
|
*/
|
||||||
function responseIdFromBodyPath(path: string): string {
|
function responseBodyRoute(responseId: string): string {
|
||||||
const segments = path.split(/[/\\]/);
|
return `/responses/${encodeURIComponent(responseId)}/body`;
|
||||||
return segments[segments.length - 1] ?? path;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -221,25 +220,33 @@ export function createBridgePlatform(baseUrl: string, token: string | null): Pla
|
|||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
async readFile(path) {
|
readDir: async () => {
|
||||||
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
|
throw unsupported("Browsing the filesystem");
|
||||||
|
},
|
||||||
|
|
||||||
|
// App resources are served by the page's own origin, so the path the
|
||||||
|
// caller resolved is already a URL a tab can load.
|
||||||
|
url: (path) => path,
|
||||||
|
|
||||||
|
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
||||||
|
resolveResource: async (path) => path,
|
||||||
|
|
||||||
|
// The route takes the response id and looks the body up itself, so the
|
||||||
|
// page never names a file — the same reason the desktop asks the backend
|
||||||
|
// for the path instead of building one.
|
||||||
|
async readResponseBody(responseId) {
|
||||||
|
const res = await connection.fetch(responseBodyRoute(responseId));
|
||||||
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to read response body (${res.status})`);
|
throw new Error(`Failed to read response body (${res.status})`);
|
||||||
}
|
}
|
||||||
return new Uint8Array(await res.arrayBuffer());
|
return new Uint8Array(await res.arrayBuffer());
|
||||||
},
|
},
|
||||||
|
|
||||||
readDir: async () => {
|
|
||||||
throw unsupported("Browsing the filesystem");
|
|
||||||
},
|
|
||||||
|
|
||||||
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
|
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
|
||||||
// The token rides in the query because the browser makes these requests
|
// The token rides in the query because the browser makes these requests
|
||||||
// itself and the page cannot add a header to them.
|
// itself and the page cannot add a header to them.
|
||||||
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
|
responseBodyUrl: async (responseId) => connection.url(responseBodyRoute(responseId)),
|
||||||
|
|
||||||
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
|
|
||||||
resolveResource: async (path) => path,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
|
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
|
||||||
|
|||||||
@@ -104,6 +104,17 @@ async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the backend put a response's body, or null if it has none.
|
||||||
|
*
|
||||||
|
* The app holds response ids and nothing else; a path exists for exactly as
|
||||||
|
* long as it takes this host to open the file, which is the one thing a desktop
|
||||||
|
* host can do that a tab cannot.
|
||||||
|
*/
|
||||||
|
function responseBodyPath(responseId: string): Promise<string | null> {
|
||||||
|
return rpc<string | null>("cmd_http_response_body_path", { responseId });
|
||||||
|
}
|
||||||
|
|
||||||
export function createTauriPlatform(): Platform {
|
export function createTauriPlatform(): Platform {
|
||||||
const window = createWindow();
|
const window = createWindow();
|
||||||
|
|
||||||
@@ -124,11 +135,20 @@ export function createTauriPlatform(): Platform {
|
|||||||
},
|
},
|
||||||
|
|
||||||
files: {
|
files: {
|
||||||
readFile: (path) => readFile(path),
|
|
||||||
readDir: (path) => readDir(path),
|
readDir: (path) => readDir(path),
|
||||||
url: (path) => convertFileSrc(path),
|
url: (path) => convertFileSrc(path),
|
||||||
basename: (path) => basename(path),
|
basename: (path) => basename(path),
|
||||||
resolveResource: (path) => resolveResource(path),
|
resolveResource: (path) => resolveResource(path),
|
||||||
|
|
||||||
|
async readResponseBody(responseId) {
|
||||||
|
const path = await responseBodyPath(responseId);
|
||||||
|
return path == null ? null : readFile(path);
|
||||||
|
},
|
||||||
|
|
||||||
|
async responseBodyUrl(responseId) {
|
||||||
|
const path = await responseBodyPath(responseId);
|
||||||
|
return path == null ? null : convertFileSrc(path);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
rpc,
|
rpc,
|
||||||
|
|||||||
@@ -135,11 +135,15 @@ export interface PlatformDialog {
|
|||||||
* File-ish operations, keyed by paths the backend handed us.
|
* File-ish operations, keyed by paths the backend handed us.
|
||||||
*
|
*
|
||||||
* A path here is an opaque handle, not something to parse or construct: the UI
|
* A path here is an opaque handle, not something to parse or construct: the UI
|
||||||
* only ever passes one back to `readFile` or `url`. A host without a filesystem
|
* only ever passes one back to `url` or `readDir`. A host without a filesystem
|
||||||
* can mint handles of its own (a blob id, a URL) and stay compatible.
|
* can mint handles of its own (a blob id, a URL) and stay compatible.
|
||||||
|
*
|
||||||
|
* Response bodies are addressed by response id instead, because they are the
|
||||||
|
* one thing every host stores somewhere different. The desktop reads the file
|
||||||
|
* the engine wrote; the bridge fetches it over HTTP. Neither is ever handed a
|
||||||
|
* location the page chose.
|
||||||
*/
|
*/
|
||||||
export interface PlatformFiles {
|
export interface PlatformFiles {
|
||||||
readFile(path: string): Promise<Uint8Array<ArrayBuffer>>;
|
|
||||||
readDir(path: string): Promise<DirEntry[]>;
|
readDir(path: string): Promise<DirEntry[]>;
|
||||||
|
|
||||||
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
|
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
|
||||||
@@ -149,6 +153,19 @@ export interface PlatformFiles {
|
|||||||
|
|
||||||
/** Resolve a path bundled with the app itself, rather than one from the backend. */
|
/** Resolve a path bundled with the app itself, rather than one from the backend. */
|
||||||
resolveResource(path: string): Promise<string>;
|
resolveResource(path: string): Promise<string>;
|
||||||
|
|
||||||
|
/** The bytes of a stored response body, or null if the response has none. */
|
||||||
|
readResponseBody(responseId: string): Promise<Uint8Array<ArrayBuffer> | null>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A URL the media viewers can point an element at, or null if the response
|
||||||
|
* has no stored body.
|
||||||
|
*
|
||||||
|
* Asynchronous because the host may have to ask the backend where the body
|
||||||
|
* is: only the host is allowed to know that, and only the moment before it
|
||||||
|
* opens it.
|
||||||
|
*/
|
||||||
|
responseBodyUrl(responseId: string): Promise<string | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Platform {
|
export interface Platform {
|
||||||
|
|||||||
Reference in New Issue
Block a user