mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Make the multipart viewer tolerant of preambles, batch responses and cut-off bodies (#821)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c629d4c66c
commit
f63586f9f0
5 files changed
+992
-43
No files matched your search
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, test, vi } from "vite-plus/test";
|
||||||
|
import {
|
||||||
|
GMAIL_BATCH_BODY,
|
||||||
|
GMAIL_BATCH_BOUNDARY,
|
||||||
|
GMAIL_BATCH_CONTENT_TYPE,
|
||||||
|
} from "../../lib/multipart.fixtures";
|
||||||
|
import { MAX_MULTIPART_DEPTH, MultipartViewer } from "./MultipartViewer";
|
||||||
|
|
||||||
|
// Stand-ins that show which viewer a part went to, without the editor or the tab machinery
|
||||||
|
vi.mock("./TextViewer", () => ({
|
||||||
|
TextViewer: ({ text, language }: { text: string; language?: string }) => (
|
||||||
|
<pre data-viewer="text" data-language={language}>
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("./HexdumpViewer", () => ({
|
||||||
|
HexdumpViewer: ({ data }: { data: Uint8Array }) => (
|
||||||
|
<pre data-viewer="hexdump">{data.length} bytes</pre>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("../tabs/Tabs", () => ({
|
||||||
|
Tabs: ({ tabs, children }: { tabs: { label: string }[]; children: ReactNode }) => (
|
||||||
|
<div>
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button key={t.label} type="button">
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
TabContent: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
||||||
|
}));
|
||||||
|
vi.mock("../HeadersTable", () => ({
|
||||||
|
HeadersTable: ({ headers }: { headers: { name: string; value: string }[] }) => (
|
||||||
|
<dl>
|
||||||
|
{headers.map((h) => (
|
||||||
|
<dt key={h.name + h.value}>
|
||||||
|
{h.name}: {h.value}
|
||||||
|
</dt>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const bytes = (s: string) => new TextEncoder().encode(s);
|
||||||
|
|
||||||
|
describe("MultipartViewer", () => {
|
||||||
|
test("renders Gmail's batch response as a nested HTTP response", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<MultipartViewer
|
||||||
|
data={bytes(GMAIL_BATCH_BODY)}
|
||||||
|
boundary={GMAIL_BATCH_CONTENT_TYPE.split("boundary=")[1]!}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(markup).not.toContain("Failed to parse");
|
||||||
|
expect(markup).toContain(">response-gtlr_43499</button>");
|
||||||
|
expect(markup).toContain("200");
|
||||||
|
expect(markup).toContain("OK");
|
||||||
|
expect(markup).toContain("Vary: X-Origin");
|
||||||
|
expect(markup).toContain('data-viewer="text" data-language="json"');
|
||||||
|
expect(markup).toContain(""messagesTotal": 23545");
|
||||||
|
expect(markup).not.toContain(GMAIL_BATCH_BOUNDARY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a multipart part recursively", () => {
|
||||||
|
const body = [
|
||||||
|
"--outer",
|
||||||
|
'Content-Type: multipart/alternative; boundary="inner"',
|
||||||
|
"",
|
||||||
|
"--inner",
|
||||||
|
"Content-Type: text/plain",
|
||||||
|
"",
|
||||||
|
"deep",
|
||||||
|
"--inner--",
|
||||||
|
"--outer--",
|
||||||
|
].join("\r\n");
|
||||||
|
const markup = renderToStaticMarkup(<MultipartViewer data={bytes(body)} boundary="outer" />);
|
||||||
|
expect(markup).toContain('data-viewer="text"');
|
||||||
|
expect(markup).toContain(">deep</pre>");
|
||||||
|
expect(markup).not.toContain("--inner");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows multipart nested past the limit raw, with a note", () => {
|
||||||
|
// Six levels, each a multipart part holding the next, the innermost a text part
|
||||||
|
let body = ["--l6", "Content-Type: text/plain", "", "innermost", "--l6--"].join("\r\n");
|
||||||
|
for (let level = 5; level >= 1; level--) {
|
||||||
|
body = [
|
||||||
|
`--l${level}`,
|
||||||
|
`Content-Type: multipart/mixed; boundary=l${level + 1}`,
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
`--l${level}--`,
|
||||||
|
].join("\r\n");
|
||||||
|
}
|
||||||
|
const markup = renderToStaticMarkup(<MultipartViewer data={bytes(body)} boundary="l1" />);
|
||||||
|
expect(markup).toContain(`nested more than ${MAX_MULTIPART_DEPTH} levels deep`);
|
||||||
|
// Level five is shown as its raw text, so its own delimiters are still in it
|
||||||
|
expect(markup).toContain('data-viewer="text" data-language="text"');
|
||||||
|
expect(markup).toContain("--l5");
|
||||||
|
expect(markup).not.toContain(">innermost</pre>");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows a binary part as a hexdump", () => {
|
||||||
|
const head = bytes("--b\r\nContent-Type: application/octet-stream\r\n\r\n");
|
||||||
|
const tail = bytes("\r\n--b--");
|
||||||
|
const data = new Uint8Array([...head, 0x00, 0x01, 0xff, 0xfe, ...tail]);
|
||||||
|
const markup = renderToStaticMarkup(<MultipartViewer data={data} boundary="b" />);
|
||||||
|
expect(markup).toContain('data-viewer="hexdump">4 bytes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("notes a body cut off before its closing delimiter", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<MultipartViewer data={bytes("preamble\r\n--b\r\n\r\npartial")} boundary="b" />,
|
||||||
|
);
|
||||||
|
expect(markup).toContain("ends without its closing boundary");
|
||||||
|
expect(markup).toContain(">partial</pre>");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,24 @@
|
|||||||
import { type MultipartPart, parseMultipart } from "@mjackson/multipart-parser";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { languageFromContentType } from "../../lib/contentType";
|
import { languageFromContentType } from "../../lib/contentType";
|
||||||
|
import {
|
||||||
|
boundaryFromContentType,
|
||||||
|
headerValue,
|
||||||
|
type HttpMessage,
|
||||||
|
isProbablyBinary,
|
||||||
|
type MultipartParseResult,
|
||||||
|
type MultipartPart,
|
||||||
|
parseHttpMessage,
|
||||||
|
parseMultipart,
|
||||||
|
} from "../../lib/multipart";
|
||||||
import { Banner } from "../Banner";
|
import { Banner } from "../Banner";
|
||||||
|
import { HeadersTable } from "../HeadersTable";
|
||||||
import { Icon } from "../Icon";
|
import { Icon } from "../Icon";
|
||||||
|
import { HttpStatusTagRaw } from "../tags/HttpStatusTag";
|
||||||
import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs";
|
import { TabContent, type TabContentProps, Tabs } from "../tabs/Tabs";
|
||||||
import { AudioViewer } from "./AudioViewer";
|
import { AudioViewer } from "./AudioViewer";
|
||||||
import { CsvViewer } from "./CsvViewer";
|
import { CsvViewer } from "./CsvViewer";
|
||||||
|
import { HexdumpViewer } from "./HexdumpViewer";
|
||||||
import { ImageViewer } from "./ImageViewer";
|
import { ImageViewer } from "./ImageViewer";
|
||||||
import { SvgViewer } from "./SvgViewer";
|
import { SvgViewer } from "./SvgViewer";
|
||||||
import { TextViewer, type TextViewerProps } from "./TextViewer";
|
import { TextViewer, type TextViewerProps } from "./TextViewer";
|
||||||
@@ -15,6 +27,7 @@ import { WebPageViewer } from "./WebPageViewer";
|
|||||||
|
|
||||||
export interface MultipartViewerProps {
|
export interface MultipartViewerProps {
|
||||||
data: Uint8Array;
|
data: Uint8Array;
|
||||||
|
/** The boundary, bare or as cut from a `Content-Type` header (quotes and later parameters are dropped) */
|
||||||
boundary: string;
|
boundary: string;
|
||||||
idPrefix?: string;
|
idPrefix?: string;
|
||||||
/** Renders a PDF part, which shows as text without it */
|
/** Renders a PDF part, which shows as text without it */
|
||||||
@@ -24,35 +37,47 @@ export interface MultipartViewerProps {
|
|||||||
ErrorBoundary?: TabContentProps["ErrorBoundary"];
|
ErrorBoundary?: TabContentProps["ErrorBoundary"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MultipartViewer({
|
/** How many multipart levels show as tabs; a part nested deeper shows raw, with a note */
|
||||||
|
export const MAX_MULTIPART_DEPTH = 4;
|
||||||
|
|
||||||
|
type ViewerOptions = Pick<
|
||||||
|
MultipartViewerProps,
|
||||||
|
"renderPdf" | "textViewerProps" | "ErrorBoundary"
|
||||||
|
> & {
|
||||||
|
/** The multipart levels shown so far, this one included */
|
||||||
|
depth: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MultipartViewer(props: MultipartViewerProps) {
|
||||||
|
return <MultipartParts {...props} depth={1} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MultipartParts({
|
||||||
data,
|
data,
|
||||||
boundary,
|
boundary,
|
||||||
idPrefix = "multipart",
|
idPrefix = "multipart",
|
||||||
renderPdf,
|
...options
|
||||||
textViewerProps,
|
}: MultipartViewerProps & { depth: number }) {
|
||||||
ErrorBoundary,
|
// Parsed once per bytes and boundary, however often this renders
|
||||||
}: MultipartViewerProps) {
|
const parseResult = useMemo<
|
||||||
const parseResult = useMemo(() => {
|
{ result: MultipartParseResult; error: null } | { result: null; error: string }
|
||||||
|
>(() => {
|
||||||
try {
|
try {
|
||||||
const maxFileSize = 1024 * 1024 * 10; // 10MB
|
return { result: parseMultipart(data, boundary), error: null };
|
||||||
const parsed = parseMultipart(data, { boundary, maxFileSize });
|
|
||||||
const parts = Array.from(parsed);
|
|
||||||
return { parts, error: null };
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { parts: [], error: err instanceof Error ? err.message : String(err) };
|
return { result: null, error: err instanceof Error ? err.message : String(err) };
|
||||||
}
|
}
|
||||||
}, [data, boundary]);
|
}, [data, boundary]);
|
||||||
|
|
||||||
const { parts, error } = parseResult;
|
if (parseResult.error != null) {
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<Banner color="danger" className="m-3">
|
<Banner color="danger" className="m-3">
|
||||||
Failed to parse multipart data: {error}
|
Failed to parse multipart data: {parseResult.error}
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { parts, complete } = parseResult.result;
|
||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Banner color="info" className="m-3">
|
<Banner color="info" className="m-3">
|
||||||
@@ -61,20 +86,21 @@ export function MultipartViewer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const tabs = (
|
||||||
<Tabs
|
<Tabs
|
||||||
addBorders
|
addBorders
|
||||||
label="Multipart"
|
label="Multipart"
|
||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
tabListClassName="border-r border-r-border -ml-3"
|
tabListClassName="border-r border-r-border -ml-3"
|
||||||
tabs={parts.map((part, i) => ({
|
tabs={parts.map((part, i) => ({
|
||||||
label: part.name ?? "",
|
label: partLabel(part, i),
|
||||||
value: tabValue(part, i),
|
value: tabValue(part, i),
|
||||||
rightSlot:
|
rightSlot:
|
||||||
part.filename && part.headers.contentType.mediaType?.startsWith("image/") ? (
|
part.filename && part.mediaType?.startsWith("image/") ? (
|
||||||
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
<div className="h-5 w-5 overflow-auto flex items-center justify-end">
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
data={part.arrayBuffer}
|
data={toArrayBuffer(part.body)}
|
||||||
|
mimeType={part.mediaType}
|
||||||
className="ml-auto w-auto rounded-sm overflow-hidden"
|
className="ml-auto w-auto rounded-sm overflow-hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,59 +112,182 @@ export function MultipartViewer({
|
|||||||
{parts.map((part, i) => (
|
{parts.map((part, i) => (
|
||||||
<TabContent
|
<TabContent
|
||||||
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
|
// oxlint-disable-next-line react/no-array-index-key -- Nothing else to key on
|
||||||
key={idPrefix + part.name + i}
|
key={idPrefix + (part.name ?? "") + i}
|
||||||
value={tabValue(part, i)}
|
value={tabValue(part, i)}
|
||||||
className="pl-3 pt-0!"
|
className="pl-3 pt-0!"
|
||||||
ErrorBoundary={ErrorBoundary}
|
ErrorBoundary={options.ErrorBoundary}
|
||||||
>
|
>
|
||||||
<Part part={part} renderPdf={renderPdf} textViewerProps={textViewerProps} />
|
<Part part={part} idPrefix={`${idPrefix}.${i}`} {...options} />
|
||||||
</TabContent>
|
</TabContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (complete) return tabs;
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<Banner color="warning" className="mb-2 mr-3 text-sm">
|
||||||
|
The body ends without its closing boundary; showing the parts that arrived.
|
||||||
|
</Banner>
|
||||||
|
{tabs}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Part({
|
function Part({
|
||||||
part,
|
part,
|
||||||
|
idPrefix,
|
||||||
|
...options
|
||||||
|
}: { part: MultipartPart; idPrefix: string } & ViewerOptions) {
|
||||||
|
if (part.mediaType === "application/http") {
|
||||||
|
const message = parseHttpMessage(part.body);
|
||||||
|
if (message != null) {
|
||||||
|
return <HttpMessagePart message={message} idPrefix={idPrefix} {...options} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PartBody body={part.body} contentType={part.contentType} idPrefix={idPrefix} {...options} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An `application/http` part, as in Google and OData batches: an HTTP message of its own */
|
||||||
|
function HttpMessagePart({
|
||||||
|
message,
|
||||||
|
idPrefix,
|
||||||
|
...options
|
||||||
|
}: { message: HttpMessage; idPrefix: string } & ViewerOptions) {
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)] gap-y-2">
|
||||||
|
<div className="font-mono text-sm pt-1 truncate">
|
||||||
|
{message.status != null ? (
|
||||||
|
<HttpStatusTagRaw
|
||||||
|
status={message.status}
|
||||||
|
statusReason={message.statusReason}
|
||||||
|
showReason
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span className="text-info">{message.method}</span> {message.url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<HeadersTable title="Headers" headers={message.headers} labelColor="primary" defaultOpen />
|
||||||
|
<PartBody
|
||||||
|
body={message.body}
|
||||||
|
contentType={headerValue(message.headers, "content-type")}
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
{...options}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A body by its own content type: the viewer the response pane would pick for it */
|
||||||
|
function PartBody({
|
||||||
|
body,
|
||||||
|
contentType,
|
||||||
|
idPrefix,
|
||||||
|
...options
|
||||||
|
}: {
|
||||||
|
body: Uint8Array;
|
||||||
|
contentType: string | null;
|
||||||
|
idPrefix: string;
|
||||||
|
} & ViewerOptions) {
|
||||||
|
const mediaType = contentType?.split(";")[0]?.trim().toLowerCase() || null;
|
||||||
|
|
||||||
|
if (body.length === 0) {
|
||||||
|
return <div className="text-text-subtlest text-sm italic pt-1">Empty</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestedBoundary = mediaType?.startsWith("multipart/")
|
||||||
|
? boundaryFromContentType(contentType)
|
||||||
|
: null;
|
||||||
|
if (nestedBoundary != null) {
|
||||||
|
if (options.depth < MAX_MULTIPART_DEPTH) {
|
||||||
|
// The nested body is a view into the outer one's bytes, parsed only when shown
|
||||||
|
return (
|
||||||
|
<MultipartParts
|
||||||
|
{...options}
|
||||||
|
data={body}
|
||||||
|
boundary={nestedBoundary}
|
||||||
|
idPrefix={idPrefix}
|
||||||
|
depth={options.depth + 1}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
|
<Banner color="info" className="mb-2 mr-3 text-sm">
|
||||||
|
Multipart nested more than {MAX_MULTIPART_DEPTH} levels deep; shown raw.
|
||||||
|
</Banner>
|
||||||
|
<LeafBody body={body} contentType={null} mediaType={null} raw {...options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <LeafBody body={body} contentType={contentType} mediaType={mediaType} {...options} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A body that isn't multipart, decoded only here, where a viewer needs its text */
|
||||||
|
function LeafBody({
|
||||||
|
body,
|
||||||
|
contentType,
|
||||||
|
mediaType,
|
||||||
|
raw,
|
||||||
renderPdf,
|
renderPdf,
|
||||||
textViewerProps,
|
textViewerProps,
|
||||||
}: { part: MultipartPart } & Pick<MultipartViewerProps, "renderPdf" | "textViewerProps">) {
|
}: {
|
||||||
const mimeType = part.headers.contentType.mediaType ?? null;
|
body: Uint8Array;
|
||||||
const contentTypeHeader = part.headers.get("content-type");
|
contentType: string | null;
|
||||||
|
mediaType: string | null;
|
||||||
|
/** Text or a hexdump, whatever the content type says */
|
||||||
|
raw?: boolean;
|
||||||
|
} & ViewerOptions) {
|
||||||
|
const { content, detectedLanguage, binary } = useMemo(() => {
|
||||||
|
const binary = isBinaryPart(mediaType, body);
|
||||||
|
const content = new TextDecoder().decode(body);
|
||||||
|
const detectedLanguage = raw ? "text" : languageFromContentType(contentType, content);
|
||||||
|
return { content, detectedLanguage, binary };
|
||||||
|
}, [body, contentType, mediaType, raw]);
|
||||||
|
|
||||||
const { uint8Array, content, detectedLanguage } = useMemo(() => {
|
if (raw) {
|
||||||
const uint8Array = new Uint8Array(part.arrayBuffer);
|
return binary ? (
|
||||||
const content = new TextDecoder().decode(part.arrayBuffer);
|
<HexdumpViewer data={body} renderEditor={textViewerProps?.renderEditor} />
|
||||||
const detectedLanguage = languageFromContentType(contentTypeHeader, content);
|
) : (
|
||||||
return { uint8Array, content, detectedLanguage };
|
<TextViewer {...textViewerProps} text={content} language="text" stateKey={null} />
|
||||||
}, [part, contentTypeHeader]);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/^image\/svg/i)) {
|
if (mediaType?.match(/^image\/svg/i)) {
|
||||||
return <SvgViewer text={content} className="pb-2" />;
|
return <SvgViewer text={content} className="pb-2" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/^image/i)) {
|
if (mediaType?.match(/^image/i)) {
|
||||||
return <ImageViewer data={part.arrayBuffer} className="pb-2" />;
|
return <ImageViewer data={toArrayBuffer(body)} mimeType={mediaType} className="pb-2" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/^audio/i)) {
|
if (mediaType?.match(/^audio/i)) {
|
||||||
return <AudioViewer data={uint8Array} />;
|
return <AudioViewer data={body} mimeType={mediaType} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/^video/i)) {
|
if (mediaType?.match(/^video/i)) {
|
||||||
return <VideoViewer data={uint8Array} />;
|
return <VideoViewer data={body} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
if (mediaType?.match(/csv|tab-separated/i)) {
|
||||||
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
return <CsvViewer text={content} className="bg-primary h-10 w-10" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/^text\/html/i) || detectedLanguage === "html") {
|
if (mediaType?.match(/^text\/html/i) || (!binary && detectedLanguage === "html")) {
|
||||||
return <WebPageViewer html={content} />;
|
return <WebPageViewer html={content} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mimeType?.match(/pdf/i) && renderPdf != null) {
|
if (mediaType?.match(/pdf/i) && renderPdf != null) {
|
||||||
return renderPdf(uint8Array);
|
return renderPdf(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (binary) {
|
||||||
|
return <HexdumpViewer data={body} renderEditor={textViewerProps?.renderEditor} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -146,6 +295,25 @@ function Part({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Text types are shown as text even with odd bytes; anything else is binary if its bytes say so */
|
||||||
|
function isBinaryPart(mediaType: string | null, body: Uint8Array): boolean {
|
||||||
|
if (
|
||||||
|
mediaType != null &&
|
||||||
|
/^text\/|json|xml|javascript|yaml|csv|x-www-form-urlencoded/.test(mediaType)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isProbablyBinary(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function partLabel(part: MultipartPart, i: number): string {
|
||||||
|
return part.name || part.filename || part.contentId || `Part ${i + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
function tabValue(part: MultipartPart, i: number) {
|
function tabValue(part: MultipartPart, i: number) {
|
||||||
return `${part.name ?? ""}::${i}`;
|
return `${part.name ?? ""}::${i}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||||
|
return bytes.slice().buffer;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Multipart bodies for the parser and viewer tests
|
||||||
|
|
||||||
|
/** Gmail's batch response, as captured: a blank line, then one `application/http` part */
|
||||||
|
export const GMAIL_BATCH_BOUNDARY = "batch_8Js-Xj0pJXhmxHz6uTLe9MxOxKn8BPHl";
|
||||||
|
export const GMAIL_BATCH_CONTENT_TYPE = `multipart/mixed; boundary=${GMAIL_BATCH_BOUNDARY}`;
|
||||||
|
export const GMAIL_BATCH_JSON = [
|
||||||
|
"{",
|
||||||
|
' "id": "IMPORTANT",',
|
||||||
|
' "name": "IMPORTANT",',
|
||||||
|
' "messageListVisibility": "hide",',
|
||||||
|
' "labelListVisibility": "labelShow",',
|
||||||
|
' "type": "system",',
|
||||||
|
' "messagesTotal": 23545,',
|
||||||
|
' "messagesUnread": 12,',
|
||||||
|
' "threadsTotal": 19203,',
|
||||||
|
' "threadsUnread": 10',
|
||||||
|
"}",
|
||||||
|
].join("\n");
|
||||||
|
export const GMAIL_BATCH_BODY = [
|
||||||
|
"",
|
||||||
|
`--${GMAIL_BATCH_BOUNDARY}`,
|
||||||
|
"Content-Type: application/http",
|
||||||
|
"Content-ID: response-gtlr_43499",
|
||||||
|
"",
|
||||||
|
"HTTP/1.1 200 OK",
|
||||||
|
"Content-Type: application/json; charset=UTF-8",
|
||||||
|
"Vary: Origin",
|
||||||
|
"Vary: X-Origin",
|
||||||
|
"Vary: Referer",
|
||||||
|
"",
|
||||||
|
GMAIL_BATCH_JSON,
|
||||||
|
`--${GMAIL_BATCH_BOUNDARY}--`,
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import {
|
||||||
|
boundaryFromContentType,
|
||||||
|
headerValue,
|
||||||
|
isProbablyBinary,
|
||||||
|
normalizeBoundary,
|
||||||
|
parseHttpMessage,
|
||||||
|
parseMultipart,
|
||||||
|
} from "./multipart";
|
||||||
|
import {
|
||||||
|
GMAIL_BATCH_BODY,
|
||||||
|
GMAIL_BATCH_BOUNDARY,
|
||||||
|
GMAIL_BATCH_CONTENT_TYPE,
|
||||||
|
} from "./multipart.fixtures";
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const bytes = (s: string) => encoder.encode(s);
|
||||||
|
const text = (b: Uint8Array) => decoder.decode(b);
|
||||||
|
|
||||||
|
describe("parseMultipart", () => {
|
||||||
|
test("parses Gmail's batch response, skipping the blank line before the first boundary", () => {
|
||||||
|
const boundary = boundaryFromContentType(GMAIL_BATCH_CONTENT_TYPE)!;
|
||||||
|
const { parts, complete } = parseMultipart(bytes(GMAIL_BATCH_BODY), boundary);
|
||||||
|
|
||||||
|
expect(complete).toBe(true);
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
const part = parts[0]!;
|
||||||
|
expect(part.mediaType).toBe("application/http");
|
||||||
|
expect(part.contentId).toBe("response-gtlr_43499");
|
||||||
|
|
||||||
|
const message = parseHttpMessage(part.body)!;
|
||||||
|
expect(message.status).toBe(200);
|
||||||
|
expect(message.statusReason).toBe("OK");
|
||||||
|
expect(message.headers.filter((h) => h.name === "Vary").map((h) => h.value)).toEqual([
|
||||||
|
"Origin",
|
||||||
|
"X-Origin",
|
||||||
|
"Referer",
|
||||||
|
]);
|
||||||
|
expect(headerValue(message.headers, "content-type")).toBe("application/json; charset=UTF-8");
|
||||||
|
expect(JSON.parse(text(message.body))).toMatchObject({ id: "IMPORTANT", messagesTotal: 23545 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("skips a text preamble and ignores the epilogue", () => {
|
||||||
|
const body = [
|
||||||
|
"This is a multi-part message in MIME format.",
|
||||||
|
"--b",
|
||||||
|
'Content-Disposition: form-data; name="a"',
|
||||||
|
"",
|
||||||
|
"one",
|
||||||
|
"--b",
|
||||||
|
'Content-Disposition: form-data; name="b"',
|
||||||
|
"",
|
||||||
|
"two",
|
||||||
|
"--b--",
|
||||||
|
"This is the epilogue. --b",
|
||||||
|
].join("\r\n");
|
||||||
|
const { parts, complete } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(complete).toBe(true);
|
||||||
|
expect(parts.map((p) => [p.name, text(p.body)])).toEqual([
|
||||||
|
["a", "one"],
|
||||||
|
["b", "two"],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shows the parts that arrived when the closing delimiter is missing", () => {
|
||||||
|
const body = ["--b", "Content-Type: text/plain", "", "one", "--b", "", "two cut sh"].join(
|
||||||
|
"\r\n",
|
||||||
|
);
|
||||||
|
const { parts, complete } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(complete).toBe(false);
|
||||||
|
expect(parts.map((p) => text(p.body))).toEqual(["one", "two cut sh"]);
|
||||||
|
expect(parts[1]!.headers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tolerates a missing final line break and LF-only line endings", () => {
|
||||||
|
const body = ["--b", "Content-Type: text/plain", "", "one", "--b--"].join("\n");
|
||||||
|
const { parts, complete } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(complete).toBe(true);
|
||||||
|
expect(text(parts[0]!.body)).toBe("one");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses a multipart part nested inside a part", () => {
|
||||||
|
const inner = ["--inner", "Content-Type: text/plain", "", "deep", "--inner--"].join("\r\n");
|
||||||
|
const body = [
|
||||||
|
"--outer",
|
||||||
|
'Content-Type: multipart/alternative; boundary="inner"',
|
||||||
|
"",
|
||||||
|
inner,
|
||||||
|
"--outer",
|
||||||
|
"Content-Type: text/plain",
|
||||||
|
"",
|
||||||
|
"shallow",
|
||||||
|
"--outer--",
|
||||||
|
].join("\r\n");
|
||||||
|
const outer = parseMultipart(bytes(body), "outer");
|
||||||
|
expect(outer.parts).toHaveLength(2);
|
||||||
|
const nested = outer.parts[0]!;
|
||||||
|
expect(nested.mediaType).toBe("multipart/alternative");
|
||||||
|
const innerResult = parseMultipart(nested.body, boundaryFromContentType(nested.contentType)!);
|
||||||
|
expect(innerResult.complete).toBe(true);
|
||||||
|
expect(text(innerResult.parts[0]!.body)).toBe("deep");
|
||||||
|
expect(text(outer.parts[1]!.body)).toBe("shallow");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a binary part's bytes exactly", () => {
|
||||||
|
const png = new Uint8Array([
|
||||||
|
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0xff, 0x0d, 0x0a,
|
||||||
|
]);
|
||||||
|
const head = bytes(
|
||||||
|
[
|
||||||
|
"--b",
|
||||||
|
'Content-Disposition: form-data; name="file"; filename="a.bin"',
|
||||||
|
"Content-Type: application/octet-stream",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n"),
|
||||||
|
);
|
||||||
|
const tail = bytes("\r\n--b--\r\n");
|
||||||
|
const body = new Uint8Array([...head, ...png, ...tail]);
|
||||||
|
const { parts } = parseMultipart(body, "b");
|
||||||
|
expect(parts[0]!.filename).toBe("a.bin");
|
||||||
|
expect(Array.from(parts[0]!.body)).toEqual(Array.from(png));
|
||||||
|
expect(isProbablyBinary(parts[0]!.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles quoted boundaries with special characters", () => {
|
||||||
|
const boundary = "a'b(c)+_,-./:=? d";
|
||||||
|
const contentType = `multipart/form-data; boundary="${boundary}"; charset=utf-8`;
|
||||||
|
expect(boundaryFromContentType(contentType)).toBe(boundary);
|
||||||
|
// What the panes cut from the header after `boundary=`
|
||||||
|
expect(normalizeBoundary(`"${boundary}"; charset=utf-8`)).toBe(boundary);
|
||||||
|
expect(normalizeBoundary("simple; charset=utf-8")).toBe("simple");
|
||||||
|
|
||||||
|
const body = [`--${boundary}`, "", "x", `--${boundary}--`].join("\r\n");
|
||||||
|
const { parts } = parseMultipart(bytes(body), `"${boundary}"`);
|
||||||
|
expect(parts.map((p) => text(p.body))).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not take a longer boundary for this one", () => {
|
||||||
|
const body = ["--b", "", "x", "--bb", "still x", "--b--"].join("\r\n");
|
||||||
|
const { parts } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(parts.map((p) => text(p.body))).toEqual(["x\r\n--bb\r\nstill x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("takes a line for a delimiter only when the whole line is one", () => {
|
||||||
|
const body = [
|
||||||
|
"--b",
|
||||||
|
"",
|
||||||
|
"x",
|
||||||
|
"--b-suffix",
|
||||||
|
"--b--suffix",
|
||||||
|
"still x",
|
||||||
|
"--b ",
|
||||||
|
"",
|
||||||
|
"y",
|
||||||
|
"--b-- ",
|
||||||
|
"epilogue",
|
||||||
|
].join("\r\n");
|
||||||
|
const { parts, complete } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(complete).toBe(true);
|
||||||
|
expect(parts.map((p) => text(p.body))).toEqual([
|
||||||
|
"x\r\n--b-suffix\r\n--b--suffix\r\nstill x",
|
||||||
|
"y",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads quoted and extended disposition parameters", () => {
|
||||||
|
const body = [
|
||||||
|
"--b",
|
||||||
|
`Content-Disposition: form-data; name="a;b"; filename*=UTF-8''r%C3%A9sum%C3%A9.txt`,
|
||||||
|
"",
|
||||||
|
"x",
|
||||||
|
"--b--",
|
||||||
|
].join("\r\n");
|
||||||
|
const { parts } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(parts[0]!.name).toBe("a;b");
|
||||||
|
expect(parts[0]!.filename).toBe("résumé.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reads UTF-8 names, quoted semicolons and a filename in its declared charset", () => {
|
||||||
|
const body = [
|
||||||
|
"--b",
|
||||||
|
'Content-Disposition: form-data; filename="a; name=wrong"; name="café"',
|
||||||
|
"",
|
||||||
|
"x",
|
||||||
|
"--b",
|
||||||
|
"Content-Disposition: attachment; filename*=ISO-8859-1''caf%E9.txt",
|
||||||
|
"",
|
||||||
|
"y",
|
||||||
|
"--b--",
|
||||||
|
].join("\r\n");
|
||||||
|
const { parts } = parseMultipart(bytes(body), "b");
|
||||||
|
expect(parts[0]!.name).toBe("café");
|
||||||
|
expect(parts[0]!.filename).toBe("a; name=wrong");
|
||||||
|
expect(parts[1]!.filename).toBe("café.txt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps every byte of a part cut off before its delimiter", () => {
|
||||||
|
const { parts } = parseMultipart(bytes("--b\r\n\r\nhello\r\n"), "b");
|
||||||
|
expect(text(parts[0]!.body)).toBe("hello\r\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a quoted boundary's leading space, however often it's normalized", () => {
|
||||||
|
const contentType = 'multipart/mixed; boundary=" b"';
|
||||||
|
const boundary = boundaryFromContentType(contentType)!;
|
||||||
|
expect(boundary).toBe(" b");
|
||||||
|
expect(normalizeBoundary(boundary)).toBe(" b");
|
||||||
|
const { parts } = parseMultipart(bytes("-- b\r\n\r\nx\r\n-- b--"), boundary);
|
||||||
|
expect(parts.map((p) => text(p.body))).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("throws when the boundary never appears", () => {
|
||||||
|
expect(() => parseMultipart(bytes("nothing here"), "b")).toThrow(/No "--b" delimiter/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses the same bytes once", () => {
|
||||||
|
const data = bytes(GMAIL_BATCH_BODY);
|
||||||
|
expect(parseMultipart(data, GMAIL_BATCH_BOUNDARY)).toBe(
|
||||||
|
parseMultipart(data, GMAIL_BATCH_BOUNDARY),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseHttpMessage", () => {
|
||||||
|
test("parses a request line, as in an OData batch", () => {
|
||||||
|
const message = parseHttpMessage(
|
||||||
|
bytes("GET /people(1) HTTP/1.1\r\nAccept: application/json\r\n\r\n"),
|
||||||
|
)!;
|
||||||
|
expect(message.method).toBe("GET");
|
||||||
|
expect(message.url).toBe("/people(1)");
|
||||||
|
expect(message.status).toBeNull();
|
||||||
|
expect(message.body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for something that isn't HTTP", () => {
|
||||||
|
expect(parseHttpMessage(bytes("hello world"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
/**
|
||||||
|
* A forgiving multipart parser for viewing bodies, not for validating them. It follows RFC 2046
|
||||||
|
* but shows whatever it can: a preamble before the first delimiter and an epilogue after the
|
||||||
|
* last are skipped, LF line endings are accepted as well as CRLF, and a body cut off before its
|
||||||
|
* closing delimiter still gives the parts that arrived.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface MultipartHeader {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartPart {
|
||||||
|
headers: MultipartHeader[];
|
||||||
|
/** The part's `Content-Type` header as sent, or null when it has none */
|
||||||
|
contentType: string | null;
|
||||||
|
/** The lowercased type and subtype of `contentType`, like `application/json` */
|
||||||
|
mediaType: string | null;
|
||||||
|
/** The `name` parameter of `Content-Disposition` */
|
||||||
|
name: string | null;
|
||||||
|
/** The `filename` parameter of `Content-Disposition` */
|
||||||
|
filename: string | null;
|
||||||
|
/** The `Content-ID` header, without angle brackets */
|
||||||
|
contentId: string | null;
|
||||||
|
body: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MultipartParseResult {
|
||||||
|
parts: MultipartPart[];
|
||||||
|
/** Whether the closing `--boundary--` delimiter was found */
|
||||||
|
complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpMessage {
|
||||||
|
/** `HTTP/1.1 200 OK` or `GET /path HTTP/1.1`, as sent */
|
||||||
|
startLine: string;
|
||||||
|
/** The status code, for a response */
|
||||||
|
status: number | null;
|
||||||
|
/** The reason phrase, for a response */
|
||||||
|
statusReason: string | null;
|
||||||
|
/** The method, for a request */
|
||||||
|
method: string | null;
|
||||||
|
/** The target, for a request */
|
||||||
|
url: string | null;
|
||||||
|
headers: MultipartHeader[];
|
||||||
|
body: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CR = 0x0d;
|
||||||
|
const LF = 0x0a;
|
||||||
|
const DASH = 0x2d;
|
||||||
|
const SPACE = 0x20;
|
||||||
|
const TAB = 0x09;
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const latin1 = new TextDecoder("latin1");
|
||||||
|
const utf8 = new TextDecoder("utf-8", { fatal: true });
|
||||||
|
|
||||||
|
/** Results already parsed, by the bytes and boundary they came from, for as long as those bytes live */
|
||||||
|
const parsed = new WeakMap<Uint8Array, Map<string, MultipartParseResult>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The boundary from a `Content-Type` header, or from a value cut out of one, without quotes or
|
||||||
|
* the parameters that follow it. Null when there is none.
|
||||||
|
*/
|
||||||
|
export function boundaryFromContentType(contentType: string | null | undefined): string | null {
|
||||||
|
if (contentType == null) return null;
|
||||||
|
const boundary = headerParams(contentType).get("boundary");
|
||||||
|
return boundary == null || boundary === "" ? null : boundary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A boundary as it appears after `--`: surrounding quotes removed, escapes undone, and anything
|
||||||
|
* after an unquoted `;` dropped, so a value like `"a b"; charset=x` gives `a b`. A boundary can't
|
||||||
|
* hold `"` or `;`, so normalizing one that already is gives it back unchanged, leading spaces and
|
||||||
|
* all.
|
||||||
|
*/
|
||||||
|
export function normalizeBoundary(boundary: string | null | undefined): string | null {
|
||||||
|
if (boundary == null) return null;
|
||||||
|
let value = boundary;
|
||||||
|
if (value.trimStart().startsWith('"')) {
|
||||||
|
const end = /^"((?:[^"\\]|\\.)*)"/.exec(value.trimStart());
|
||||||
|
value = end != null ? end[1]!.replace(/\\(.)/g, "$1") : value.trimStart().slice(1);
|
||||||
|
} else {
|
||||||
|
// Trailing whitespace isn't part of a boundary (RFC 2046)
|
||||||
|
value = value.split(";")[0]!.trimEnd();
|
||||||
|
}
|
||||||
|
return value === "" ? null : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a multipart body, made once for the same bytes and boundary */
|
||||||
|
export function parseMultipart(data: Uint8Array, rawBoundary: string): MultipartParseResult {
|
||||||
|
const boundary = normalizeBoundary(rawBoundary) ?? rawBoundary;
|
||||||
|
let byBoundary = parsed.get(data);
|
||||||
|
const cached = byBoundary?.get(boundary);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
const result = parseMultipartUncached(data, boundary);
|
||||||
|
if (byBoundary == null) {
|
||||||
|
byBoundary = new Map();
|
||||||
|
parsed.set(data, byBoundary);
|
||||||
|
}
|
||||||
|
byBoundary.set(boundary, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultipartUncached(data: Uint8Array, boundary: string): MultipartParseResult {
|
||||||
|
const delimiter = encoder.encode(`--${boundary}`);
|
||||||
|
|
||||||
|
// The first delimiter starts the body or a line; anything before it is preamble
|
||||||
|
let start = findDelimiter(data, delimiter, 0);
|
||||||
|
if (start < 0) {
|
||||||
|
throw new Error(`No "--${boundary}" delimiter found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: MultipartPart[] = [];
|
||||||
|
for (;;) {
|
||||||
|
let pos = start + delimiter.length;
|
||||||
|
|
||||||
|
// `--boundary--` closes the body; whatever follows is epilogue
|
||||||
|
if (data[pos] === DASH && data[pos + 1] === DASH) {
|
||||||
|
return { parts, complete: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transport padding, then the line break that ends the delimiter line
|
||||||
|
while (data[pos] === SPACE || data[pos] === TAB) pos++;
|
||||||
|
if (data[pos] === CR) pos++;
|
||||||
|
if (data[pos] === LF) pos++;
|
||||||
|
|
||||||
|
if (pos >= data.length) {
|
||||||
|
return { parts, complete: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = findDelimiter(data, delimiter, pos);
|
||||||
|
if (next < 0) {
|
||||||
|
// Cut off before the next delimiter: the rest is the last part, as far as it got
|
||||||
|
parts.push(parsePart(data.subarray(pos)));
|
||||||
|
return { parts, complete: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The line break before a delimiter belongs to the delimiter, not the part
|
||||||
|
const end = next === pos ? pos : lineStart(data, next);
|
||||||
|
parts.push(parsePart(data.subarray(pos, end)));
|
||||||
|
start = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the next delimiter at the start of a line begins, at or after `from`, or -1. A match must
|
||||||
|
* be followed by `--`, whitespace, a line break or the end of the data, so a longer boundary that
|
||||||
|
* merely starts with this one isn't taken for it.
|
||||||
|
*/
|
||||||
|
function findDelimiter(data: Uint8Array, delimiter: Uint8Array, from: number): number {
|
||||||
|
let i = data.indexOf(delimiter[0]!, from);
|
||||||
|
while (i >= 0 && i + delimiter.length <= data.length) {
|
||||||
|
if (
|
||||||
|
(i === 0 || data[i - 1] === LF) &&
|
||||||
|
matchesAt(data, delimiter, i) &&
|
||||||
|
endsDelimiter(data, i + delimiter.length)
|
||||||
|
) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
i = data.indexOf(delimiter[0]!, i + 1);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAt(data: Uint8Array, needle: Uint8Array, at: number): boolean {
|
||||||
|
for (let j = 0; j < needle.length; j++) {
|
||||||
|
if (data[at + j] !== needle[j]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the rest of the line after a boundary completes a delimiter: an optional `--` for the
|
||||||
|
* closing one, optional transport padding, then a line break or the end of the data. So with
|
||||||
|
* boundary `b`, neither `--b-suffix` nor `--b--suffix` is a delimiter.
|
||||||
|
*/
|
||||||
|
function endsDelimiter(data: Uint8Array, at: number): boolean {
|
||||||
|
let pos = at;
|
||||||
|
if (data[pos] === DASH && data[pos + 1] === DASH) pos += 2;
|
||||||
|
while (data[pos] === SPACE || data[pos] === TAB) pos++;
|
||||||
|
if (pos >= data.length) return true;
|
||||||
|
if (data[pos] === LF) return true;
|
||||||
|
return data[pos] === CR && (pos + 1 >= data.length || data[pos + 1] === LF);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The start of the line break before `at` (CRLF or LF), or `at` when there is none */
|
||||||
|
function lineStart(data: Uint8Array, at: number): number {
|
||||||
|
if (at > 0 && data[at - 1] === LF) {
|
||||||
|
return at > 1 && data[at - 2] === CR ? at - 2 : at - 1;
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePart(bytes: Uint8Array): MultipartPart {
|
||||||
|
const { headers, body } = splitHeaders(bytes);
|
||||||
|
const contentType = headerValue(headers, "content-type");
|
||||||
|
const disposition = headerParams(headerValue(headers, "content-disposition") ?? "");
|
||||||
|
const contentId = headerValue(headers, "content-id");
|
||||||
|
const extendedFilename = disposition.get("filename*");
|
||||||
|
return {
|
||||||
|
headers,
|
||||||
|
contentType,
|
||||||
|
mediaType: mediaTypeOf(contentType),
|
||||||
|
name: disposition.get("name") ?? null,
|
||||||
|
filename:
|
||||||
|
extendedFilename != null
|
||||||
|
? decodeExtendedValue(extendedFilename)
|
||||||
|
: (disposition.get("filename") ?? null),
|
||||||
|
contentId: contentId == null ? null : contentId.replace(/^<(.*)>$/, "$1"),
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a header block from the body after it, at the first blank line. A part that starts with
|
||||||
|
* a blank line has no headers.
|
||||||
|
*/
|
||||||
|
function splitHeaders(bytes: Uint8Array): { headers: MultipartHeader[]; body: Uint8Array } {
|
||||||
|
let pos = 0;
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (;;) {
|
||||||
|
const lf = bytes.indexOf(LF, pos);
|
||||||
|
const lineEnd = lf < 0 ? bytes.length : lf;
|
||||||
|
const contentEnd = lineEnd > pos && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd;
|
||||||
|
if (contentEnd === pos) {
|
||||||
|
// A blank line ends the headers; without one, it was all headers and no body
|
||||||
|
pos = lf < 0 ? bytes.length : lf + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const line = decodeHeaderLine(bytes.subarray(pos, contentEnd));
|
||||||
|
if (lines.length === 0 && !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+\s*:/.test(line)) {
|
||||||
|
// Not a header, so a part sent without its blank line: all of it is body
|
||||||
|
return { headers: [], body: bytes };
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
if (lf < 0) {
|
||||||
|
pos = bytes.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pos = lf + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: MultipartHeader[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
const last = headers[headers.length - 1];
|
||||||
|
if ((line.startsWith(" ") || line.startsWith("\t")) && last != null) {
|
||||||
|
// A folded line continues the header before it
|
||||||
|
last.value = `${last.value} ${line.trim()}`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const colon = line.indexOf(":");
|
||||||
|
if (colon <= 0) continue;
|
||||||
|
headers.push({ name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim() });
|
||||||
|
}
|
||||||
|
return { headers, body: bytes.subarray(pos) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an `application/http` part: a request or response line, its headers and its body. Null
|
||||||
|
* when the bytes don't start with one.
|
||||||
|
*/
|
||||||
|
export function parseHttpMessage(bytes: Uint8Array): HttpMessage | null {
|
||||||
|
// Leading blank lines are tolerated, as HTTP/1.1 servers do
|
||||||
|
let start = 0;
|
||||||
|
while (bytes[start] === CR || bytes[start] === LF) start++;
|
||||||
|
const lf = bytes.indexOf(LF, start);
|
||||||
|
const lineEnd = lf < 0 ? bytes.length : lf;
|
||||||
|
const startLine = latin1
|
||||||
|
.decode(
|
||||||
|
bytes.subarray(start, lineEnd > start && bytes[lineEnd - 1] === CR ? lineEnd - 1 : lineEnd),
|
||||||
|
)
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const response = /^(HTTP\/\d(?:\.\d)?)\s+(\d{3})(?:\s+(.*))?$/i.exec(startLine);
|
||||||
|
const request = /^([A-Z]+)\s+(\S+)(?:\s+HTTP\/\d(?:\.\d)?)?$/.exec(startLine);
|
||||||
|
if (response == null && request == null) return null;
|
||||||
|
|
||||||
|
const rest = lf < 0 ? new Uint8Array() : bytes.subarray(lf + 1);
|
||||||
|
const { headers, body } = splitHeaders(rest);
|
||||||
|
return {
|
||||||
|
startLine,
|
||||||
|
status: response != null ? Number(response[2]) : null,
|
||||||
|
statusReason: response?.[3]?.trim() || null,
|
||||||
|
method: response == null ? (request?.[1] ?? null) : null,
|
||||||
|
url: response == null ? (request?.[2] ?? null) : null,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A header line as UTF-8, as browsers send form field names, or Latin-1 when it isn't valid UTF-8 */
|
||||||
|
function decodeHeaderLine(bytes: Uint8Array): string {
|
||||||
|
try {
|
||||||
|
return utf8.decode(bytes);
|
||||||
|
} catch {
|
||||||
|
return latin1.decode(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headerValue(headers: MultipartHeader[], name: string): string | null {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
return headers.find((h) => h.name.toLowerCase() === lower)?.value ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaTypeOf(contentType: string | null): string | null {
|
||||||
|
if (contentType == null) return null;
|
||||||
|
const essence = contentType.split(";")[0]!.trim().toLowerCase();
|
||||||
|
return essence === "" ? null : essence;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameters after a header's first `;`, like `form-data; name="a"; filename="b.txt"`, by
|
||||||
|
* lowercased name and unquoted. A quoted value may hold `;` and `=` without ending it.
|
||||||
|
*/
|
||||||
|
function headerParams(header: string): Map<string, string> {
|
||||||
|
const params = new Map<string, string>();
|
||||||
|
let i = header.indexOf(";");
|
||||||
|
if (i < 0) return params;
|
||||||
|
while (i < header.length) {
|
||||||
|
// Past the `;` and whitespace, then the name up to `=` or the next `;`
|
||||||
|
while (i < header.length && (header[i] === ";" || header[i] === " " || header[i] === "\t")) i++;
|
||||||
|
const nameStart = i;
|
||||||
|
while (i < header.length && header[i] !== "=" && header[i] !== ";") i++;
|
||||||
|
const name = header.slice(nameStart, i).trim().toLowerCase();
|
||||||
|
if (header[i] !== "=") continue;
|
||||||
|
i++;
|
||||||
|
while (header[i] === " " || header[i] === "\t") i++;
|
||||||
|
let value = "";
|
||||||
|
if (header[i] === '"') {
|
||||||
|
i++;
|
||||||
|
while (i < header.length && header[i] !== '"') {
|
||||||
|
if (header[i] === "\\" && i + 1 < header.length) i++;
|
||||||
|
value += header[i];
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
// Past the closing quote and anything up to the next parameter
|
||||||
|
while (i < header.length && header[i] !== ";") i++;
|
||||||
|
} else {
|
||||||
|
const valueStart = i;
|
||||||
|
while (i < header.length && header[i] !== ";") i++;
|
||||||
|
value = header.slice(valueStart, i).trim();
|
||||||
|
}
|
||||||
|
if (name !== "" && !params.has(name)) params.set(name, value);
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An RFC 5987 extended value, `charset'language'percent-encoded`, decoded in its charset */
|
||||||
|
function decodeExtendedValue(value: string): string {
|
||||||
|
const ext = /^([^']*)'[^']*'(.*)$/.exec(value);
|
||||||
|
if (ext == null) return value;
|
||||||
|
const encoded = ext[2]!;
|
||||||
|
const bytes: number[] = [];
|
||||||
|
for (let i = 0; i < encoded.length; i++) {
|
||||||
|
const hex = encoded.slice(i + 1, i + 3);
|
||||||
|
if (encoded[i] === "%" && /^[0-9a-f]{2}$/i.test(hex)) {
|
||||||
|
bytes.push(parseInt(hex, 16));
|
||||||
|
i += 2;
|
||||||
|
} else {
|
||||||
|
bytes.push(encoded.charCodeAt(i) & 0xff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new TextDecoder(ext[1]!.trim() || "utf-8").decode(new Uint8Array(bytes));
|
||||||
|
} catch {
|
||||||
|
return encoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether bytes look binary: not valid UTF-8, or holding NUL or other control bytes */
|
||||||
|
export function isProbablyBinary(bytes: Uint8Array): boolean {
|
||||||
|
const sample = bytes.subarray(0, 8192);
|
||||||
|
for (const b of sample) {
|
||||||
|
if (b === 0) return true;
|
||||||
|
if (b < 0x20 && b !== TAB && b !== LF && b !== CR && b !== 0x0c && b !== 0x1b) return true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user