mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-23 20:04:05 +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 { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
|
||||
import { useResponseBodyBytes, useResponseBodyText } from "../hooks/useResponseBodyText";
|
||||
import { useResponseBodyUrl } from "../hooks/useResponseBodyUrl";
|
||||
import { useResponseViewMode } from "../hooks/useResponseViewMode";
|
||||
import { useSaveResponse } from "../hooks/useSaveResponse";
|
||||
import { useTimelineViewMode } from "../hooks/useTimelineViewMode";
|
||||
@@ -409,14 +410,13 @@ function EnsureCompleteResponse({
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
Component: ComponentType<{ url: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
// Wait until the response has been fully-downloaded before asking for it
|
||||
const complete = response.state === "closed";
|
||||
const bodyUrl = useResponseBodyUrl(complete ? response : null);
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== "closed") {
|
||||
if (!complete || bodyUrl.isPending) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<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 }) {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL the host resolved, for a body it already stored. */
|
||||
url?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
|
||||
export function AudioViewer({ url, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (url) {
|
||||
setSrc(url);
|
||||
} else if (data) {
|
||||
// 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
|
||||
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [url, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
return <audio className="w-full" controls src={src} />;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
type Props = { className?: string; mimeType?: string } & (
|
||||
| {
|
||||
bodyPath: string;
|
||||
/** A URL the host resolved, for a body it already stored. */
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
data: ArrayBuffer;
|
||||
@@ -13,21 +13,21 @@ type Props = { className?: string; mimeType?: string } & (
|
||||
|
||||
export function ImageViewer({ className, mimeType, ...props }: Props) {
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath != null) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (url != null) {
|
||||
setSrc(url);
|
||||
} else if (data != null) {
|
||||
const blob = new Blob([data], { type: mimeType ?? "image/png" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [url, data, mimeType]);
|
||||
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useMemo, useRef, useState } from "react";
|
||||
import { Document, Page } from "react-pdf";
|
||||
import { useContainerSize } from "@yaakapp-internal/ui";
|
||||
import { fireAndForget } from "../../lib/fireAndForget";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
fireAndForget(
|
||||
import("react-pdf").then(({ pdfjs }) => {
|
||||
@@ -18,7 +17,8 @@ fireAndForget(
|
||||
);
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL the host resolved, for a body it already stored. */
|
||||
url?: string;
|
||||
data?: Uint8Array;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ const options = {
|
||||
standardFontDataUrl: "/standard_fonts/",
|
||||
};
|
||||
|
||||
export function PdfViewer({ bodyPath, data }: Props) {
|
||||
export function PdfViewer({ url, data }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
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
|
||||
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
|
||||
const src = useMemo(() => {
|
||||
if (bodyPath) {
|
||||
return platform.files.url(bodyPath);
|
||||
if (url) {
|
||||
return url;
|
||||
}
|
||||
if (data) {
|
||||
// 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 undefined;
|
||||
}, [bodyPath, data]);
|
||||
}, [url, data]);
|
||||
|
||||
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
|
||||
setNumPages(nextNumPages);
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
interface Props {
|
||||
bodyPath?: string;
|
||||
/** A URL the host resolved, for a body it already stored. */
|
||||
url?: string;
|
||||
data?: Uint8Array;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
|
||||
export function VideoViewer({ url, data, mimeType }: Props) {
|
||||
const [src, setSrc] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (bodyPath) {
|
||||
setSrc(platform.files.url(bodyPath));
|
||||
if (url) {
|
||||
setSrc(url);
|
||||
} else if (data) {
|
||||
// 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 url = URL.createObjectURL(blob);
|
||||
setSrc(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
setSrc(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
} else {
|
||||
setSrc(undefined);
|
||||
}
|
||||
}, [bodyPath, data, mimeType]);
|
||||
}, [url, data, mimeType]);
|
||||
|
||||
// oxlint-disable-next-line jsx-a11y/media-has-caption
|
||||
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 { 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({
|
||||
response,
|
||||
filter,
|
||||
@@ -13,7 +19,7 @@ export async function getResponseBodyText({
|
||||
filter: string | null;
|
||||
}): Promise<string | null> {
|
||||
const result = await rpc<FilterResponse>("cmd_http_response_body", {
|
||||
response,
|
||||
responseId: response.id,
|
||||
filter,
|
||||
});
|
||||
|
||||
@@ -27,10 +33,9 @@ export async function getResponseBodyText({
|
||||
export async function getResponseBodyEventSource(
|
||||
response: HttpResponse,
|
||||
): Promise<ServerSentEvent[]> {
|
||||
if (!response.bodyPath) return [];
|
||||
try {
|
||||
const events = await rpc<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||
filePath: response.bodyPath,
|
||||
responseId: response.id,
|
||||
});
|
||||
if (events.length > 0) {
|
||||
return events;
|
||||
@@ -39,8 +44,9 @@ export async function getResponseBodyEventSource(
|
||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
||||
}
|
||||
|
||||
const bytes = await platform.files.readFile(response.bodyPath);
|
||||
const text = new TextDecoder("utf-8").decode(bytes);
|
||||
const text = await getResponseBodyDecoded(response);
|
||||
if (text == null) return [];
|
||||
|
||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
||||
data,
|
||||
eventType: "",
|
||||
@@ -53,16 +59,19 @@ export async function getResponseBodySseSummary(
|
||||
response: HttpResponse,
|
||||
resultKeyPath: string,
|
||||
): 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);
|
||||
}
|
||||
|
||||
export async function getResponseBodyBytes(
|
||||
response: HttpResponse,
|
||||
): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
if (!response.bodyPath) return null;
|
||||
return platform.files.readFile(response.bodyPath);
|
||||
return platform.files.readResponseBody(response.id);
|
||||
}
|
||||
|
||||
async function getResponseBodyDecoded(response: HttpResponse): Promise<string | null> {
|
||||
const bytes = await getResponseBodyBytes(response);
|
||||
return bytes == null ? null : new TextDecoder("utf-8").decode(bytes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user