feat: support Office documents in chat attachments

Users can now attach Microsoft Office documents (.docx, .pptx, and .xlsx) and OpenDocument files (.odt, .odp, and .ods) from the shared web, desktop, mobile, and VS Code chat surfaces.

Document text is extracted locally and sent as a text/plain file part with the original filename, keeping the visible user message clean. Supported embedded PNG, JPEG, GIF, and WebP images are sent as separate image parts, with matching [filename] citations preserved near their source paragraph, slide object, spreadsheet cell anchor, or OpenDocument position. Presentation notes, spreadsheet values, headers, and footers are included where available.

Document expansion is metadata-validated and bounded against oversized entries, excessive uncompressed data, unsafe paths, invalid image signatures, attachment-name races, and dangling citations after truncation. Generated document parts are published to the composer atomically.

Add fflate for worker-backed ZIP extraction and narrowly allow blob workers in the VS Code webview CSP without permitting blob scripts. Include focused fixtures for every supported format, extraction limits, positional citations, collision recovery, atomic attachment state, and CSP behavior.
This commit is contained in:
Bohdan Triapitsyn
2026-07-22 13:11:42 +03:00
parent d7a93c5ec0
commit fd0f6a6bac
12 changed files with 985 additions and 25 deletions
+3
View File
@@ -182,6 +182,7 @@
"cron-parser": "^5.5.0",
"dompurify": "^3.2.7",
"express": "^5.1.0",
"fflate": "^0.8.3",
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
@@ -2000,6 +2001,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
+1
View File
@@ -58,6 +58,7 @@
"cron-parser": "^5.5.0",
"dompurify": "^3.2.7",
"express": "^5.1.0",
"fflate": "^0.8.3",
"fuse.js": "^7.1.0",
"ghostty-web": "^0.4.0",
"heic2any": "^0.0.4",
+3
View File
@@ -48,12 +48,15 @@ So:
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
| `input-store.ts` | Draft input state, attached files, synthetic parts | App UI state |
| `selection-store.ts` | Model/agent/variant selections | App UI state |
| `voice-store.ts` | Voice state | App UI state |
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
## Session list rules
### Directory bootstrap scheduling
@@ -16,6 +16,7 @@ describe("attachment file preparation", () => {
for (const extension of [
"diff", "patch", "ipynb", "jsonl", "ndjson", "har", "svg", "drawio",
"vue", "svelte", "php", "cs", "kt", "swift", "lua", "dart", "tf", "hcl", "proto",
"docx", "pptx", "xlsx", "odt", "odp", "ods",
]) {
expect(ACCEPTED_ATTACHMENT_EXTENSIONS.includes(extension)).toBe(true)
expect(ATTACHMENT_ACCEPT.includes(`.${extension}`)).toBe(true)
+43 -1
View File
@@ -6,6 +6,12 @@ const ACCEPTED_ATTACHMENT_TYPES = [
"image/heic",
"image/heif",
"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.spreadsheet",
"text/*",
"application/json",
"application/ld+json",
@@ -27,6 +33,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [
".cts",
".dart",
".diff",
".docx",
".drawio",
".env",
".erl",
@@ -64,9 +71,13 @@ const ACCEPTED_ATTACHMENT_TYPES = [
".mjs",
".mts",
".ndjson",
".odp",
".ods",
".odt",
".patch",
".php",
".proto",
".pptx",
".ps1",
".py",
".r",
@@ -88,6 +99,7 @@ const ACCEPTED_ATTACHMENT_TYPES = [
".txt",
".vue",
".xml",
".xlsx",
".yaml",
".yml",
".zig",
@@ -104,6 +116,12 @@ const PICKER_MIME_EXTENSIONS = new Map<string, string>([
["image/heic", "heic"],
["image/heif", "heif"],
["application/pdf", "pdf"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "docx"],
["application/vnd.openxmlformats-officedocument.presentationml.presentation", "pptx"],
["application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx"],
["application/vnd.oasis.opendocument.text", "odt"],
["application/vnd.oasis.opendocument.presentation", "odp"],
["application/vnd.oasis.opendocument.spreadsheet", "ods"],
["application/json", "json"],
["application/ld+json", "jsonld"],
["application/toml", "toml"],
@@ -157,11 +175,12 @@ const TEXT_MIMES = new Set([
"image/svg+xml",
])
const ATTACHMENT_SAMPLE_BYTES = 4096
const DOCUMENT_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"])
const REDACTED = "[REDACTED]"
const OMITTED = "[OMITTED BY OPENCHAMBER]"
const SENSITIVE_NAMES = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|client[-_]?secret|password|secret|access[-_]?token|refresh[-_]?token|id[-_]?token|token)$/i
export type PreparedAttachmentFile = {
type PreparedAttachmentFile = {
file: File
mimeType: string
}
@@ -341,3 +360,26 @@ export const prepareAttachmentFile = (
if (typeof mime === "string") return { file, mimeType: mime }
return mime?.then((mimeType) => mimeType ? { file, mimeType } : undefined)
}
export const prepareAttachmentFiles = (
file: File,
reservedFilenames: Iterable<string> = [],
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
const prepared = prepareAttachmentFile(file)
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
return prepared ? [prepared] : undefined
}
return import("./document-attachments").then(async ({ extractDocumentAttachments }) => {
const extracted = await extractDocumentAttachments(file, reservedFilenames)
if (!extracted) return
const prepared: PreparedAttachmentFile[] = [{ file: extracted.textFile, mimeType: "text/plain" }]
for (const image of extracted.images) {
const output = await prepareAttachmentFile(image)
if (!output || !output.mimeType.startsWith("image/")) return
prepared.push(output)
}
return prepared
})
}
@@ -0,0 +1,212 @@
import { describe, expect, test } from "bun:test"
import { strToU8, zipSync } from "fflate"
import { extractDocumentAttachments } from "./document-attachments"
const zippedFile = (name: string, entries: Record<string, string | Uint8Array>) => new File([
zipSync(Object.fromEntries(Object.entries(entries).map(([path, value]) => [
path,
typeof value === "string" ? strToU8(value) : value,
]))),
], name)
const relationships = (items: Array<{ id: string; target: string; type?: string }>) => `
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
${items.map((item) => `<Relationship Id="${item.id}" Target="${item.target}" Type="${item.type ?? "image"}"/>`).join("")}
</Relationships>
`
const pngBytes = (suffix = 0) => new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, suffix])
const jpegBytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0])
const webpBytes = new Uint8Array([
0x52, 0x49, 0x46, 0x46, 0x04, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
])
describe("document attachment extraction", () => {
test("extracts DOCX text and preserves inline image citations", async () => {
const file = zippedFile("report.docx", {
"word/document.xml": `
<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r">
<w:body>
<w:p><w:r><w:t>Before image</w:t></w:r></w:p>
<w:p><w:r><w:drawing><a:blip r:embed="rId1"/></w:drawing></w:r></w:p>
<w:p><w:r><w:t>After image</w:t></w:r></w:p>
</w:body>
</w:document>`,
"word/_rels/document.xml.rels": relationships([{ id: "rId1", target: "media/image1.png" }]),
"word/media/image1.png": pngBytes(),
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.includes("Before image\n\n[report-image-1.png]\n\nAfter image")).toBe(true)
expect(result?.textFile.name).toBe("report.docx")
expect(result?.textFile.type.startsWith("text/plain")).toBe(true)
expect(result?.images).toHaveLength(1)
expect(result?.images[0]?.name).toBe("report-image-1.png")
expect(result?.images[0]?.type).toBe("image/png")
const deduplicated = await extractDocumentAttachments(file, ["report-image-1.png"])
expect((await deduplicated?.textFile.text())?.includes("[report-image-2.png]")).toBe(true)
expect(deduplicated?.images[0]?.name).toBe("report-image-2.png")
})
test("extracts PPTX slide text, notes, and pictures", async () => {
const file = zippedFile("deck.pptx", {
"ppt/slides/slide1.xml": `
<p:sld xmlns:p="p" xmlns:a="a" xmlns:r="r">
<a:p><a:r><a:t>Slide title</a:t></a:r></a:p>
<p:pic><p:blipFill><a:blip r:embed="rIdImage"/></p:blipFill></p:pic>
</p:sld>`,
"ppt/slides/_rels/slide1.xml.rels": relationships([
{ id: "rIdImage", target: "../media/image1.jpeg" },
{ id: "rIdNotes", target: "../notesSlides/notesSlide1.xml", type: "http://example/notesSlide" },
]),
"ppt/notesSlides/notesSlide1.xml": `<p:notes xmlns:p="p" xmlns:a="a"><a:p><a:r><a:t>Speaker note</a:t></a:r></a:p></p:notes>`,
"ppt/media/image1.jpeg": jpegBytes,
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.includes("## Slide 1")).toBe(true)
expect(text.includes("Slide title")).toBe(true)
expect(text.includes("[deck-image-1.jpg]")).toBe(true)
expect(text.includes("### Slide 1 notes\n\nSpeaker note")).toBe(true)
expect(result?.images[0]?.name).toBe("deck-image-1.jpg")
})
test("extracts XLSX cell values and anchors pictures to cells", async () => {
const file = zippedFile("budget.xlsx", {
"xl/workbook.xml": `<workbook xmlns:r="r"><sheets><sheet name="Summary" r:id="rIdSheet"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]),
"xl/sharedStrings.xml": `<sst><si><t>Revenue</t></si></sst>`,
"xl/worksheets/sheet1.xml": `
<worksheet xmlns:r="r"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row></sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
"xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]),
"xl/drawings/drawing1.xml": `
<xdr:wsDr xmlns:xdr="xdr" xmlns:a="a" xmlns:r="r"><xdr:oneCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:row>2</xdr:row></xdr:from><a:blip r:embed="rIdImage"/></xdr:oneCellAnchor></xdr:wsDr>`,
"xl/drawings/_rels/drawing1.xml.rels": relationships([{ id: "rIdImage", target: "../media/image1.webp" }]),
"xl/media/image1.webp": webpBytes,
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.includes("## Sheet: Summary")).toBe(true)
expect(text.includes("A1: Revenue | B1: 42")).toBe(true)
expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true)
expect(result?.images[0]?.name).toBe("budget-image-1.webp")
})
test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => {
const image = pngBytes()
const odt = zippedFile("notes.odt", {
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:draw="draw" xmlns:xlink="xlink"><text:h>Heading</text:h><text:p>Hello <text:span>world</text:span></text:p><draw:frame><draw:image xlink:href="Pictures/photo.png"/></draw:frame><text:p>After image</text:p></office:document>`,
"Pictures/photo.png": image,
})
const odp = zippedFile("slides.odp", {
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:draw="draw"><draw:page draw:name="Intro"><text:p>Welcome</text:p></draw:page></office:document>`,
})
const ods = zippedFile("table.ods", {
"content.xml": `<office:document xmlns:office="office" xmlns:text="text" xmlns:table="table" xmlns:draw="draw" xmlns:xlink="xlink"><table:table table:name="Data"><table:shapes><draw:frame><draw:image xlink:href="Pictures/chart.png"/></draw:frame></table:shapes><table:table-row><table:table-cell><text:p>Name</text:p></table:table-cell><table:table-cell><text:p>Value</text:p></table:table-cell></table:table-row></table:table></office:document>`,
"Pictures/chart.png": image,
})
const odtResult = await extractDocumentAttachments(odt)
const odpResult = await extractDocumentAttachments(odp)
const odsResult = await extractDocumentAttachments(ods)
expect((await odtResult?.textFile.text())?.includes("Heading\n\nHello world\n\n[notes-image-1.png]\n\nAfter image")).toBe(true)
expect(odtResult?.images).toHaveLength(1)
expect((await odpResult?.textFile.text())?.includes("## Slide: Intro\n\nWelcome")).toBe(true)
expect((await odsResult?.textFile.text())?.includes("## Sheet: Data\n\n[table-image-1.png]\n\nName | Value")).toBe(true)
expect(odsResult?.images).toHaveLength(1)
})
test("rejects unsafe archive paths", async () => {
const file = zippedFile("unsafe.docx", {
"../word/document.xml": `<w:document xmlns:w="w"><w:p><w:t>Unsafe</w:t></w:p></w:document>`,
})
await expect(extractDocumentAttachments(file)).rejects.toThrow("unsafe file path")
})
test("rejects archives over the entry-count limit", async () => {
const entries = Object.fromEntries(Array.from({ length: 5_001 }, (_, index) => [
`metadata/entry-${index}.xml`,
"<metadata/>",
]))
await expect(extractDocumentAttachments(zippedFile("too-many.docx", entries))).rejects.toThrow("too many files")
})
test("bounds embedded image count and marks omitted images in document text", async () => {
const imageTags = Array.from({ length: 51 }, (_, index) => `<w:p><a:blip r:embed="rId${index}"/></w:p>`).join("")
const relationshipItems = Array.from({ length: 51 }, (_, index) => ({
id: `rId${index}`,
target: `media/image${index}.png`,
}))
const entries: Record<string, string | Uint8Array> = {
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body>${imageTags}</w:body></w:document>`,
"word/_rels/document.xml.rels": relationships(relationshipItems),
}
for (let index = 0; index < 51; index += 1) entries[`word/media/image${index}.png`] = pngBytes(index)
const result = await extractDocumentAttachments(zippedFile("gallery.docx", entries))
const text = await result?.textFile.text() ?? ""
expect(result?.images).toHaveLength(50)
expect(text.includes("[Embedded image omitted by attachment limits: image50.png]")).toBe(true)
})
test("omits unsupported and spoofed embedded image content", async () => {
const file = zippedFile("unsafe-images.docx", {
"word/document.xml": `
<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body>
<w:p><a:blip r:embed="svg"/></w:p>
<w:p><a:blip r:embed="fakePng"/></w:p>
</w:body></w:document>`,
"word/_rels/document.xml.rels": relationships([
{ id: "svg", target: "media/image.svg" },
{ id: "fakePng", target: "media/fake.png" },
]),
"word/media/image.svg": `<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`,
"word/media/fake.png": new Uint8Array([1, 2, 3]),
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(result?.images).toEqual([])
expect(text.includes("[Unsupported embedded image omitted: image.svg]")).toBe(true)
expect(text.includes("[Invalid embedded image omitted: fake.png]")).toBe(true)
})
test("bounds expanded ODF spaces", async () => {
const file = zippedFile("spaces.odt", {
"content.xml": `<office:document xmlns:office="office" xmlns:text="text"><text:p>Before<text:s text:c="999999999999999999999"/>After</text:p></office:document>`,
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.includes("[Additional spaces omitted]After")).toBe(true)
expect(text.length).toBeLessThan(1_000)
})
test("does not retain images whose citations fall beyond the text limit", async () => {
const file = zippedFile("long.docx", {
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(2_000_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
"word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]),
"word/media/image.png": pngBytes(),
})
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.length <= 2_000_000).toBe(true)
expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true)
expect(text.includes("[long-image-1.png]")).toBe(false)
expect(result?.images).toEqual([])
})
})
@@ -0,0 +1,596 @@
import { unzip, unzipSync, type UnzipFileInfo, type Unzipped } from "fflate"
const MAX_ARCHIVE_BYTES = 20 * 1024 * 1024
const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024
const MAX_ENTRY_BYTES = 25 * 1024 * 1024
const MAX_XML_ENTRY_BYTES = 8 * 1024 * 1024
const MAX_ARCHIVE_ENTRIES = 5_000
const MAX_EMBEDDED_IMAGES = 50
const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024
const MAX_EXTRACTED_TEXT_CHARS = 2_000_000
const MAX_ODF_SPACES_PER_ELEMENT = 100
const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n"
const OFFICE_EXTENSIONS = new Set(["docx", "pptx", "xlsx", "odt", "odp", "ods"])
const IMAGE_MIMES = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
])
type Relationship = { target: string; type: string }
type Relationships = Map<string, Relationship>
type ExtractedDocumentAttachments = {
textFile: File
images: File[]
}
const extensionOf = (name: string): string => {
const index = name.lastIndexOf(".")
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
}
const basenameWithoutExtension = (name: string): string => {
const basename = name.replace(/\\/g, "/").split("/").pop() || "document"
const index = basename.lastIndexOf(".")
return (index > 0 ? basename.slice(0, index) : basename).replace(/[^a-zA-Z0-9._-]+/g, "-") || "document"
}
const normalizeArchivePath = (path: string): string | undefined => {
const segments: string[] = []
for (const segment of path.replace(/\\/g, "/").split("/")) {
if (!segment || segment === ".") continue
if (segment === "..") {
if (segments.length === 0) return
segments.pop()
continue
}
segments.push(segment)
}
return segments.join("/")
}
const resolveArchivePath = (sourcePath: string, target: string): string | undefined => {
if (target.startsWith("/")) return normalizeArchivePath(target.slice(1))
const sourceDirectory = sourcePath.includes("/") ? sourcePath.slice(0, sourcePath.lastIndexOf("/") + 1) : ""
return normalizeArchivePath(`${sourceDirectory}${target}`)
}
const relationshipsPath = (sourcePath: string): string => {
const index = sourcePath.lastIndexOf("/")
const directory = index === -1 ? "" : sourcePath.slice(0, index + 1)
const filename = sourcePath.slice(index + 1)
return `${directory}_rels/${filename}.rels`
}
const decodeXmlCodePoint = (code: string, radix: number): string => {
const value = Number.parseInt(code, radix)
if (value < 0 || value > 0x10FFFF || (value >= 0xD800 && value <= 0xDFFF)) return ""
return String.fromCodePoint(value)
}
const decodeXml = (value: string): string => value
.replace(/&#x([0-9a-f]+);/gi, (_, code: string) => decodeXmlCodePoint(code, 16))
.replace(/&#([0-9]+);/g, (_, code: string) => decodeXmlCodePoint(code, 10))
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
const attribute = (tag: string, name: string): string | undefined => {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const match = tag.match(new RegExp(`(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i"))
return decodeXml(match?.[1] ?? match?.[2] ?? "") || undefined
}
const tagBlocks = (xml: string, tag: string): string[] => {
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
return Array.from(xml.matchAll(new RegExp(`<${escaped}\\b[^>]*>[\\s\\S]*?<\\/${escaped}>`, "gi")), (match) => match[0])
}
const textDecoder = new TextDecoder()
const xml = (archive: Unzipped, path: string): string => {
const bytes = archive[path]
return bytes ? textDecoder.decode(bytes) : ""
}
const parseRelationships = (archive: Unzipped, sourcePath: string): Relationships => {
const result: Relationships = new Map()
const source = xml(archive, relationshipsPath(sourcePath))
for (const match of source.matchAll(/<Relationship\b[^>]*\/?\s*>/gi)) {
const id = attribute(match[0], "Id")
const target = attribute(match[0], "Target")
if (!id || !target) continue
result.set(id, { target, type: attribute(match[0], "Type") ?? "" })
}
return result
}
const isControlCharacter = (character: string): boolean => {
const code = character.charCodeAt(0)
return code <= 0x1F || code === 0x7F
}
const hasControlCharacters = (value: string): boolean => Array.from(value).some(isControlCharacter)
const embeddedImageLabel = (path: string): string => {
const basename = path.replace(/\\/g, "/").split("/").pop() || "embedded image"
return Array.from(basename.slice(0, 200), (character) => {
if (character === "[" || character === "]") return "_"
return isControlCharacter(character) ? "_" : character
}).join("")
}
const hasBytes = (bytes: Uint8Array, expected: number[]): boolean => expected.every((value, index) => bytes[index] === value)
const hasAscii = (bytes: Uint8Array, offset: number, expected: string): boolean => {
if (bytes.byteLength < offset + expected.length) return false
for (let index = 0; index < expected.length; index += 1) {
if (bytes[offset + index] !== expected.charCodeAt(index)) return false
}
return true
}
const hasValidImageSignature = (bytes: Uint8Array, extension: string): boolean => {
switch (extension) {
case "png":
return hasBytes(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
case "jpg":
case "jpeg":
return hasBytes(bytes, [0xFF, 0xD8, 0xFF])
case "gif":
return hasAscii(bytes, 0, "GIF87a") || hasAscii(bytes, 0, "GIF89a")
case "webp":
return hasAscii(bytes, 0, "RIFF") && hasAscii(bytes, 8, "WEBP")
default:
return false
}
}
class EmbeddedImages {
private readonly files: File[] = []
private readonly filenames = new Map<string, string>()
private count = 0
private imageBytes = 0
private readonly reservedFilenames: Set<string>
constructor(
private readonly archive: Unzipped,
private readonly documentName: string,
reservedFilenames: Iterable<string>,
) {
this.reservedFilenames = new Set(Array.from(reservedFilenames, (filename) => filename.toLowerCase()))
}
citation(path: string | undefined): string {
if (!path) return "[Embedded image reference could not be resolved]"
const normalized = normalizeArchivePath(path)
if (!normalized) return "[Unsafe embedded image path omitted]"
const existing = this.filenames.get(normalized)
if (existing) return `[${existing}]`
const bytes = this.archive[normalized]
const extension = extensionOf(normalized)
const mime = IMAGE_MIMES.get(extension)
const label = embeddedImageLabel(normalized)
if (!bytes || !mime) return `[Unsupported embedded image omitted: ${label}]`
if (!hasValidImageSignature(bytes, extension)) return `[Invalid embedded image omitted: ${label}]`
if (
this.files.length >= MAX_EMBEDDED_IMAGES
|| bytes.byteLength > MAX_EMBEDDED_IMAGE_BYTES
|| this.imageBytes + bytes.byteLength > MAX_EMBEDDED_IMAGES_BYTES
) {
return `[Embedded image omitted by attachment limits: ${label}]`
}
const outputExtension = extension === "jpeg" ? "jpg" : extension
let filename: string
do {
this.count += 1
filename = `${basenameWithoutExtension(this.documentName)}-image-${this.count}.${outputExtension}`
} while (this.reservedFilenames.has(filename.toLowerCase()))
this.reservedFilenames.add(filename.toLowerCase())
this.filenames.set(normalized, filename)
this.files.push(new File([bytes], filename, { type: mime }))
this.imageBytes += bytes.byteLength
return `[${filename}]`
}
all(): File[] {
return this.files
}
}
const relationshipTarget = (sourcePath: string, relationships: Relationships, id: string | undefined): string | undefined => {
if (!id) return
const relationship = relationships.get(id)
return relationship ? resolveArchivePath(sourcePath, relationship.target) : undefined
}
const inlineText = (
block: string,
sourcePath: string,
relationships: Relationships,
images: EmbeddedImages,
): string => {
const pieces: string[] = []
const tokenPattern = /<(?:w:t|a:t|text:span)\b[^>]*>([\s\S]*?)<\/(?:w:t|a:t|text:span)>|<(?:w:tab|text:tab)\b[^>]*\/?>|<(?:w:br|a:br|text:line-break)\b[^>]*\/?>|<(?:a:blip|v:imagedata)\b[^>]*>|<draw:image\b[^>]*>/gi
for (const match of block.matchAll(tokenPattern)) {
if (match[1] !== undefined) {
pieces.push(decodeXml(match[1]).replace(/<[^>]+>/g, ""))
continue
}
if (/tab/i.test(match[0])) {
pieces.push("\t")
continue
}
if (/br|line-break/i.test(match[0])) {
pieces.push("\n")
continue
}
const relationshipId = attribute(match[0], "r:embed") ?? attribute(match[0], "r:id")
const directPath = attribute(match[0], "xlink:href")
const target = directPath
? resolveArchivePath(sourcePath, directPath)
: relationshipTarget(sourcePath, relationships, relationshipId)
pieces.push(`\n${images.citation(target)}\n`)
}
return pieces.join("").replace(/[ \t]+\n/g, "\n").trim()
}
const paragraphs = (
source: string,
paragraphTag: string,
sourcePath: string,
relationships: Relationships,
images: EmbeddedImages,
): string[] => tagBlocks(source, paragraphTag)
.map((block) => inlineText(block, sourcePath, relationships, images))
.filter(Boolean)
const extractDocx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const documentPath = "word/document.xml"
const documentXml = xml(archive, documentPath)
if (!documentXml) return
const sections = ["# Document", ...paragraphs(documentXml, "w:p", documentPath, parseRelationships(archive, documentPath), images)]
const extras = Object.keys(archive)
.filter((path) => /^word\/(?:header|footer)\d+\.xml$/i.test(path))
.sort()
for (const path of extras) {
const content = paragraphs(xml(archive, path), "w:p", path, parseRelationships(archive, path), images)
if (content.length > 0) sections.push(`## ${path.includes("header") ? "Header" : "Footer"}`, ...content)
}
return `${sections.join("\n\n")}\n`
}
const numberedPaths = (archive: Unzipped, pattern: RegExp): string[] => Object.keys(archive)
.filter((path) => pattern.test(path))
.sort((left, right) => {
const leftNumber = Number(left.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0)
const rightNumber = Number(right.match(/(\d+)(?=\.xml$)/)?.[1] ?? 0)
return leftNumber - rightNumber
})
const extractPptx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const slidePaths = numberedPaths(archive, /^ppt\/slides\/slide\d+\.xml$/i)
if (slidePaths.length === 0) return
const sections: string[] = ["# Presentation"]
slidePaths.forEach((slidePath, index) => {
const relationships = parseRelationships(archive, slidePath)
const content = Array.from(
xml(archive, slidePath).matchAll(/<a:p\b[^>]*>[\s\S]*?<\/a:p>|<p:pic\b[^>]*>[\s\S]*?<\/p:pic>/gi),
(match) => inlineText(match[0], slidePath, relationships, images),
).filter(Boolean)
sections.push(`## Slide ${index + 1}`, ...(content.length > 0 ? content : ["[Empty slide]"]))
const notesRelationship = Array.from(relationships.values()).find((relationship) => relationship.type.endsWith("/notesSlide"))
const notesPath = notesRelationship ? resolveArchivePath(slidePath, notesRelationship.target) : undefined
if (!notesPath) return
const notes = paragraphs(xml(archive, notesPath), "a:p", notesPath, parseRelationships(archive, notesPath), images)
if (notes.length > 0) sections.push(`### Slide ${index + 1} notes`, ...notes)
})
return `${sections.join("\n\n")}\n`
}
const columnName = (index: number): string => {
let value = index + 1
let result = ""
while (value > 0) {
value -= 1
result = String.fromCharCode(65 + (value % 26)) + result
value = Math.floor(value / 26)
}
return result
}
const cellValue = (cell: string, sharedStrings: string[]): string => {
const type = attribute(cell.match(/^<c\b[^>]*>/i)?.[0] ?? "", "t")
if (type === "inlineStr") {
return Array.from(cell.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join("")
}
const value = cell.match(/<v\b[^>]*>([\s\S]*?)<\/v>/i)?.[1] ?? ""
if (type === "s") return sharedStrings[Number(value)] ?? ""
if (type === "b") return value === "1" ? "TRUE" : "FALSE"
return decodeXml(value)
}
const drawingCitations = (
archive: Unzipped,
worksheetPath: string,
images: EmbeddedImages,
): string[] => {
const worksheetXml = xml(archive, worksheetPath)
const worksheetRelationships = parseRelationships(archive, worksheetPath)
const output: string[] = []
for (const drawing of worksheetXml.matchAll(/<drawing\b[^>]*r:id=(?:"([^"]+)"|'([^']+)')[^>]*\/?\s*>/gi)) {
const drawingPath = relationshipTarget(worksheetPath, worksheetRelationships, drawing[1] ?? drawing[2])
if (!drawingPath) continue
const drawingXml = xml(archive, drawingPath)
const drawingRelationships = parseRelationships(archive, drawingPath)
for (const anchor of drawingXml.matchAll(/<xdr:(?:oneCellAnchor|twoCellAnchor)\b[^>]*>([\s\S]*?)<\/xdr:(?:oneCellAnchor|twoCellAnchor)>/gi)) {
const content = anchor[1]
const column = Number(content.match(/<xdr:col>(\d+)<\/xdr:col>/i)?.[1] ?? 0)
const row = Number(content.match(/<xdr:row>(\d+)<\/xdr:row>/i)?.[1] ?? 0)
const imageId = content.match(/<a:blip\b[^>]*r:embed=(?:"([^"]+)"|'([^']+)')[^>]*>/i)
const target = relationshipTarget(drawingPath, drawingRelationships, imageId?.[1] ?? imageId?.[2])
output.push(`Image at ${columnName(column)}${row + 1}: ${images.citation(target)}`)
}
}
return output
}
const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const workbookPath = "xl/workbook.xml"
const workbookXml = xml(archive, workbookPath)
if (!workbookXml) return
const workbookRelationships = parseRelationships(archive, workbookPath)
const sharedStrings = tagBlocks(xml(archive, "xl/sharedStrings.xml"), "si")
.map((item) => Array.from(item.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi), (match) => decodeXml(match[1]).replace(/<[^>]+>/g, "")).join(""))
const sections: string[] = ["# Workbook"]
for (const sheet of workbookXml.matchAll(/<sheet\b[^>]*\/?\s*>/gi)) {
const name = attribute(sheet[0], "name") ?? "Sheet"
const relationshipId = attribute(sheet[0], "r:id")
const worksheetPath = relationshipTarget(workbookPath, workbookRelationships, relationshipId)
if (!worksheetPath) continue
sections.push(`## Sheet: ${name}`)
const rows: string[] = []
for (const row of tagBlocks(xml(archive, worksheetPath), "row")) {
const cells = Array.from(row.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi), (match) => {
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
const reference = attribute(tag, "r") ?? "?"
return `${reference}: ${cellValue(match[0], sharedStrings)}`
}).filter((value) => !value.endsWith(": "))
if (cells.length > 0) rows.push(cells.join(" | "))
}
sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images))
}
return `${sections.join("\n\n")}\n`
}
const expandOdfSpaces = (tag: string): string => {
const rawCount = attribute(tag, "text:c")
if (!rawCount) return " "
const count = Number(rawCount)
if (Number.isSafeInteger(count) && count > 0 && count <= MAX_ODF_SPACES_PER_ELEMENT) return " ".repeat(count)
const omitted = Number.isSafeInteger(count) && count > MAX_ODF_SPACES_PER_ELEMENT
? `${count - MAX_ODF_SPACES_PER_ELEMENT} additional spaces omitted`
: "Additional spaces omitted"
return `${" ".repeat(MAX_ODF_SPACES_PER_ELEMENT)}[${omitted}]`
}
const odfInlineText = (source: string, sourcePath: string, images: EmbeddedImages): string => source
.replace(/<draw:image\b[^>]*>/gi, (tag) => {
const target = resolveArchivePath(sourcePath, attribute(tag, "xlink:href") ?? "")
return `\n${images.citation(target)}\n`
})
.replace(/<text:tab\b[^>]*\/?\s*>/gi, "\t")
.replace(/<text:line-break\b[^>]*\/?\s*>/gi, "\n")
.replace(/<text:s\b[^>]*\/?\s*>/gi, expandOdfSpaces)
.replace(/<[^>]+>/g, "")
const odfContent = (source: string, sourcePath: string, images: EmbeddedImages): string[] => {
const output: string[] = []
const contentPattern = /<text:(p|h)\b[^>]*>[\s\S]*?<\/text:\1>|<draw:image\b[^>]*>/gi
for (const match of source.matchAll(contentPattern)) {
const content = /^<draw:image\b/i.test(match[0])
? images.citation(resolveArchivePath(sourcePath, attribute(match[0], "xlink:href") ?? ""))
: decodeXml(odfInlineText(match[0], sourcePath, images)).replace(/[ \t]+\n/g, "\n").trim()
if (content) output.push(content)
}
return output
}
const extractOdt = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const contentPath = "content.xml"
const content = xml(archive, contentPath)
if (!content) return
return `${["# Document", ...odfContent(content, contentPath, images)].join("\n\n")}\n`
}
const extractOdp = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const contentPath = "content.xml"
const content = xml(archive, contentPath)
if (!content) return
const sections = ["# Presentation"]
const pages = tagBlocks(content, "draw:page")
pages.forEach((page, index) => {
const openTag = page.match(/^<draw:page\b[^>]*>/i)?.[0] ?? ""
const name = attribute(openTag, "draw:name") ?? String(index + 1)
sections.push(`## Slide: ${name}`, ...odfContent(page, contentPath, images))
})
return `${sections.join("\n\n")}\n`
}
const extractOds = (archive: Unzipped, images: EmbeddedImages): string | undefined => {
const contentPath = "content.xml"
const content = xml(archive, contentPath)
if (!content) return
const sections = ["# Workbook"]
for (const table of tagBlocks(content, "table:table")) {
const openTag = table.match(/^<table:table\b[^>]*>/i)?.[0] ?? ""
sections.push(`## Sheet: ${attribute(openTag, "table:name") ?? "Sheet"}`)
for (const shapes of tagBlocks(table, "table:shapes")) {
sections.push(...odfContent(shapes, contentPath, images))
}
for (const row of tagBlocks(table, "table:table-row")) {
const cells = tagBlocks(row, "table:table-cell")
.map((cell) => odfContent(cell, contentPath, images).join(" "))
if (cells.some(Boolean)) sections.push(cells.join(" | "))
}
}
return `${sections.join("\n\n")}\n`
}
const createArchiveEntryValidator = () => {
let entries = 0
let uncompressedBytes = 0
return (info: UnzipFileInfo): void => {
entries += 1
uncompressedBytes += info.originalSize
if (entries > MAX_ARCHIVE_ENTRIES) throw new Error("Document contains too many files")
if (info.originalSize > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file")
if (/\.(?:xml|rels)$/i.test(info.name) && info.originalSize > MAX_XML_ENTRY_BYTES) {
throw new Error("Document contains XML that is too large to process safely")
}
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit")
const normalized = normalizeArchivePath(info.name)
const canonicalName = info.name.endsWith("/") ? info.name.slice(0, -1) : info.name
if (
!normalized
|| normalized !== canonicalName
|| info.name.includes("\\")
|| /^[a-z]:/i.test(info.name)
|| hasControlCharacters(info.name)
) {
throw new Error("Document contains an unsafe file path")
}
}
}
const shouldExtractArchiveEntry = (info: UnzipFileInfo): boolean => {
const extension = extensionOf(info.name)
return extension === "xml" || extension === "rels" || IMAGE_MIMES.has(extension)
}
const validateArchiveMetadata = async (data: Uint8Array): Promise<void> => {
const validateArchiveEntry = createArchiveEntryValidator()
if ("Bun" in globalThis) {
unzipSync(data, { filter: (info) => {
validateArchiveEntry(info)
return false
} })
return
}
await new Promise<void>((resolve, reject) => {
unzip(data, { filter: (info) => {
validateArchiveEntry(info)
return false
} }, (error) => {
if (error) reject(error)
else resolve()
})
})
}
const validateExtractedArchive = (archive: Unzipped): void => {
let uncompressedBytes = 0
for (const [path, bytes] of Object.entries(archive)) {
uncompressedBytes += bytes.byteLength
if (bytes.byteLength > MAX_ENTRY_BYTES) throw new Error("Document contains an oversized file")
if (/\.(?:xml|rels)$/i.test(path) && bytes.byteLength > MAX_XML_ENTRY_BYTES) {
throw new Error("Document contains XML that is too large to process safely")
}
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) throw new Error("Document expands beyond the 100 MB safety limit")
}
}
const unzipDocument = async (file: File): Promise<Unzipped> => {
if (file.size > MAX_ARCHIVE_BYTES) throw new Error("Document exceeds the 20 MB attachment limit")
const data = new Uint8Array(await file.arrayBuffer())
await validateArchiveMetadata(data)
// Bun's browser-style Blob workers do not reliably execute fflate's async decoder.
// Production browser runtimes use the worker-backed path below.
const archive = "Bun" in globalThis
? unzipSync(data, { filter: shouldExtractArchiveEntry })
: await new Promise<Unzipped>((resolve, reject) => {
unzip(data, { filter: shouldExtractArchiveEntry }, (error, result) => {
if (error) reject(error)
else resolve(result)
})
})
validateExtractedArchive(archive)
return archive
}
const extractDocumentText = (extension: string, archive: Unzipped, images: EmbeddedImages): string | undefined => {
switch (extension) {
case "docx":
return extractDocx(archive, images)
case "pptx":
return extractPptx(archive, images)
case "xlsx":
return extractXlsx(archive, images)
case "odt":
return extractOdt(archive, images)
case "odp":
return extractOdp(archive, images)
case "ods":
return extractOds(archive, images)
default:
return undefined
}
}
const boundExtractedText = (text: string, imageFilenames: Set<string>): string => {
if (text.length <= MAX_EXTRACTED_TEXT_CHARS) return text
let end = MAX_EXTRACTED_TEXT_CHARS - TEXT_TRUNCATION_NOTICE.length
const lastOpenBracket = text.lastIndexOf("[", end - 1)
const lastCloseBracket = text.lastIndexOf("]", end - 1)
if (lastOpenBracket > lastCloseBracket) {
const nextCloseBracket = text.indexOf("]", lastOpenBracket)
const candidate = nextCloseBracket === -1 ? "" : text.slice(lastOpenBracket + 1, nextCloseBracket)
if (imageFilenames.has(candidate)) end = lastOpenBracket
}
return `${text.slice(0, end)}${TEXT_TRUNCATION_NOTICE}`
}
const citedImageFilenames = (text: string): Set<string> => {
const filenames = new Set<string>()
for (const match of text.matchAll(/\[([^\]\r\n]+)\]/g)) filenames.add(match[1])
return filenames
}
export const extractDocumentAttachments = async (
file: File,
reservedFilenames: Iterable<string> = [],
): Promise<ExtractedDocumentAttachments | undefined> => {
const extension = extensionOf(file.name)
if (!OFFICE_EXTENSIONS.has(extension)) return
const archive = await unzipDocument(file)
const images = new EmbeddedImages(archive, file.name, reservedFilenames)
const text = extractDocumentText(extension, archive, images)
if (!text) return
const extractedImages = images.all()
const imageFilenames = new Set(extractedImages.map((image) => image.name))
const boundedText = boundExtractedText(text, imageFilenames)
const citations = citedImageFilenames(boundedText)
return {
textFile: new File([boundedText], file.name, { type: "text/plain" }),
images: extractedImages.filter((image) => citations.has(image.name)),
}
}
+64
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, test } from "bun:test"
import { strToU8, zipSync } from "fflate"
import { useInputStore } from "./input-store"
class MockFileReader {
@@ -41,6 +42,14 @@ const rejectReader = (reader: MockFileReader) => {
reader.onerror?.call(reader as unknown as FileReader, {} as ProgressEvent<FileReader>)
}
const waitForReaderCount = async (count: number) => {
for (let attempt = 0; attempt < 100 && pendingReaders.length < count; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1))
}
}
const pngBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
describe("input-store attachments", () => {
beforeEach(() => {
pendingReaders.length = 0
@@ -212,4 +221,59 @@ describe("input-store attachments", () => {
expect(useInputStore.getState().attachedFiles[0]?.mimeType).toBe("image/webp")
expect(useInputStore.getState().attachedFiles[0]?.dataUrl).toBe("data:image/webp;base64,AQID")
})
testWithMockFileReader("adds extracted document text and referenced images atomically", async () => {
const archive = zipSync({
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>Diagram</w:t><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
"word/media/image.png": pngBytes,
})
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
await waitForReaderCount(1)
expect(pendingReaders).toHaveLength(1)
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
await waitForReaderCount(2)
expect(pendingReaders).toHaveLength(2)
expect(useInputStore.getState().attachedFiles).toEqual([])
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles.map((attachment) => ({
filename: attachment.filename,
mimeType: attachment.mimeType,
}))).toEqual([
{ filename: "design.docx", mimeType: "text/plain" },
{ filename: "design-image-1.png", mimeType: "image/png" },
])
})
testWithMockFileReader("regenerates document image names when the composer changes during preparation", async () => {
const archive = zipSync({
"word/document.xml": strToU8(`<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><a:blip r:embed="rId1"/></w:p></w:body></w:document>`),
"word/_rels/document.xml.rels": strToU8(`<Relationships><Relationship Id="rId1" Target="media/image.png" Type="image"/></Relationships>`),
"word/media/image.png": pngBytes,
})
const addPromise = useInputStore.getState().addAttachedFile(new File([archive], "design.docx"))
await waitForReaderCount(1)
resolveReader(pendingReaders[0], "data:text/plain;base64,RG9jdW1lbnQ=")
await waitForReaderCount(2)
useInputStore.getState().addVSCodeFileAttachment("/workspace/design-image-1.png", "design-image-1.png", 1)
resolveReader(pendingReaders[1], "data:image/png;base64,AQID")
await waitForReaderCount(3)
resolveReader(pendingReaders[2], "data:text/plain;base64,RG9jdW1lbnQ=")
await waitForReaderCount(4)
resolveReader(pendingReaders[3], "data:image/png;base64,AQID")
expect(await addPromise).toBe(true)
expect(useInputStore.getState().attachedFiles.map((attachment) => attachment.filename)).toEqual([
"design-image-1.png",
"design.docx",
"design-image-2.png",
])
const textAttachment = useInputStore.getState().attachedFiles[1]
expect((await textAttachment?.file.text())?.includes("[design-image-2.png]")).toBe(true)
})
})
+41 -23
View File
@@ -5,9 +5,10 @@
import { create } from "zustand"
import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFile } from "./attachment-files"
import { prepareAttachmentFiles } from "./attachment-files"
const FILE_URI_PREFIX = "file://"
const MAX_ATTACHMENT_PREPARATION_ATTEMPTS = 3
const pendingVSCodeSelectionKeys = new Set<string>()
let attachmentReadGeneration = 0
@@ -35,6 +36,12 @@ const toFileUrl = (filepath: string): string => {
const getVSCodeSelectionKey = (path: string, filename: string): string => `${path}\u0000${filename}`
const hasGeneratedFilenameCollision = (filenames: string[], attachedFiles: AttachedFile[]): boolean => {
if (filenames.length === 0) return false
const attachedFilenames = new Set(attachedFiles.map((attachment) => attachment.filename.toLowerCase()))
return filenames.some((filename) => attachedFilenames.has(filename.toLowerCase()))
}
const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
@@ -157,30 +164,41 @@ export const useInputStore = create<InputState>()((set, get) => ({
},
addAttachedFile: async (file: File) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`
const generation = attachmentReadGeneration
const preparedOrPending = prepareAttachmentFile(file)
// Keep the synchronous preparation path synchronous so FileReader starts before this action yields.
const prepared = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!prepared) return false
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
} catch {
return false
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false
const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
const attachedFiles: AttachedFile[] = []
for (const prepared of preparedFiles) {
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
} catch {
return false
}
if (!dataUrl || generation !== attachmentReadGeneration) return false
attachedFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
})
}
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
return true
}
if (!dataUrl || generation !== attachmentReadGeneration) return false
const attached: AttachedFile = {
id,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
}
set((s) => ({ attachedFiles: [...s.attachedFiles, attached] }))
return true
return false
},
removeAttachedFile: (id) =>
+2
View File
@@ -39,6 +39,8 @@ Keep `bridge.ts` as a thin orchestration layer that delegates message handling t
- dropped-file parsing and attachment reading
- models metadata fetch helper
The webview CSP permits `blob:` only for `worker-src` so shared UI parsers can run bounded local decompression off the main thread. Blob scripts remain disallowed by `script-src`.
- `bridge-localfs-proxy-runtime.ts`
- Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers.
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { describe, test } from 'node:test';
const source = readFileSync(new URL('./webviewHtml.ts', import.meta.url), 'utf8');
describe('VS Code webview content security policy', () => {
test('allows blob URLs for workers without allowing blob scripts', () => {
const workerSource = source.match(/const workerSrc = ([^\n]+);/)?.[1] ?? '';
const scriptSource = source.match(/const scriptSrc = ([^\n]+);/)?.[1] ?? '';
assert.match(workerSource, /'blob:'/);
assert.doesNotMatch(scriptSource, /'blob:'/);
assert.match(source, /worker-src \$\{workerSrc\}/);
});
});
+3 -1
View File
@@ -68,7 +68,9 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string {
const connectSrc = uniqueTokens(['*', 'ws:', 'wss:', 'http:', 'https:', devServerOrigin]);
const imgSrc = uniqueTokens([webview.cspSource, 'data:', 'https:', devServerOrigin]);
const fontSrc = uniqueTokens([webview.cspSource, 'data:', devServerOrigin]);
const workerSrc = uniqueTokens([webview.cspSource, devServerOrigin]);
// fflate's async browser inflater creates blob-backed workers. Keep blob:
// scoped to worker-src so document decompression works without allowing blob scripts.
const workerSrc = uniqueTokens([webview.cspSource, 'blob:', devServerOrigin]);
const themeKind = getThemeKindName(vscode.window.activeColorTheme.kind);