ofw-mcp 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -2
- package/dist/bundle.js +1081 -58
- package/dist/config.js +42 -0
- package/dist/extract/document.js +83 -0
- package/dist/extract/index.js +222 -0
- package/dist/extract/inflate.js +55 -0
- package/dist/extract/ooxml.js +58 -0
- package/dist/extract/pdf.js +278 -0
- package/dist/extract/presentation.js +54 -0
- package/dist/extract/spreadsheet.js +258 -0
- package/dist/extract/types.js +4 -0
- package/dist/extract/xml.js +61 -0
- package/dist/extract/zip.js +110 -0
- package/dist/index.js +1 -1
- package/dist/sync.js +7 -2
- package/dist/tools/delivery.js +99 -0
- package/dist/tools/draft-freshness.js +56 -4
- package/dist/tools/messages.js +191 -51
- package/package.json +3 -3
- package/server.json +14 -2
- package/skills/ofw/SKILL.md +2 -2
package/dist/config.js
CHANGED
|
@@ -82,6 +82,48 @@ export function getCalendarWritesAllowed() {
|
|
|
82
82
|
return true;
|
|
83
83
|
return mode === 'drafts' && parseBoolEnv('OFW_CALENDAR_WRITES');
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Deployment-wide ceiling on reads that STAMP the record.
|
|
87
|
+
*
|
|
88
|
+
* Fetching a message body from OFW marks it read and stamps a "First Viewed"
|
|
89
|
+
* timestamp the co-parent can see. That is part of the court-visible record and
|
|
90
|
+
* it cannot be undone — and unlike a send, it happens as a side effect of an
|
|
91
|
+
* ordinary read, so nothing about the caller's intent signals it.
|
|
92
|
+
*
|
|
93
|
+
* Default TRUE: unset means exactly the behaviour that shipped before this flag
|
|
94
|
+
* existed. Set OFW_ALLOW_MARK_READ=false and it becomes a hard ceiling rather
|
|
95
|
+
* than a default — `ofw_get_message` refuses a fetch that would stamp,
|
|
96
|
+
* `ofw_check_freshness` ignores `allowMarkRead:true`, and `ofw_sync_messages`
|
|
97
|
+
* ignores `fetchUnreadBodies:true`. A per-call argument (or an instruction
|
|
98
|
+
* injected into one) cannot raise it, which is the same structural posture
|
|
99
|
+
* OFW_WRITE_MODE takes for writes.
|
|
100
|
+
*
|
|
101
|
+
* An unrecognized value fails CLOSED, with a warning: someone who wrote
|
|
102
|
+
* "flase" meant to disable this, and honouring the typo as the permissive
|
|
103
|
+
* default would keep stamping the record while looking configured.
|
|
104
|
+
*/
|
|
105
|
+
export function getAllowMarkRead() {
|
|
106
|
+
const raw = process.env.OFW_ALLOW_MARK_READ;
|
|
107
|
+
if (typeof raw !== 'string' || raw.trim().length === 0)
|
|
108
|
+
return true;
|
|
109
|
+
const value = raw.trim().toLowerCase();
|
|
110
|
+
if (['1', 'true', 'yes', 'on'].includes(value))
|
|
111
|
+
return true;
|
|
112
|
+
if (['0', 'false', 'no', 'off'].includes(value))
|
|
113
|
+
return false;
|
|
114
|
+
// stdio transport: stderr only — stdout is reserved for JSON-RPC.
|
|
115
|
+
console.error(`[ofw-mcp] Unrecognized OFW_ALLOW_MARK_READ "${raw.trim()}" — failing closed to "false" (no tool may mark a message read on OFW). Valid values: true, false.`);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Default for ofw_sync_messages' `fetchUnreadBodies` arg. False (the shipped
|
|
120
|
+
* default) so an ordinary sync never stamps unread inbox messages; set
|
|
121
|
+
* OFW_FETCH_UNREAD_BODIES=true on a deployment where read receipts are routine
|
|
122
|
+
* and you would rather have the bodies cached. Capped by getAllowMarkRead().
|
|
123
|
+
*/
|
|
124
|
+
export function getFetchUnreadBodies() {
|
|
125
|
+
return parseBoolEnv('OFW_FETCH_UNREAD_BODIES');
|
|
126
|
+
}
|
|
85
127
|
// Default for ofw_download_attachment's `inline` arg when the caller doesn't
|
|
86
128
|
// pass one. Set OFW_INLINE_ATTACHMENTS=true to have attachments returned as
|
|
87
129
|
// MCP content blocks by default (skipping disk) — useful on sandboxed MCP
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Word (.docx) text extraction.
|
|
2
|
+
//
|
|
3
|
+
// The goal is a readable transcript, not a faithful re-render: headings keep
|
|
4
|
+
// their level as `#` prefixes, list paragraphs keep their bullet, and table
|
|
5
|
+
// rows come back pipe-delimited. Structure is what makes an extracted custody
|
|
6
|
+
// schedule answerable ("which row is Thanksgiving?"); a flat wall of runs is
|
|
7
|
+
// not much better than the blob we are replacing.
|
|
8
|
+
import { readZip } from './zip.js';
|
|
9
|
+
import { decodeXmlEntities, elements } from './xml.js';
|
|
10
|
+
import { findPart } from './ooxml.js';
|
|
11
|
+
// A run's text, a tab, or a break — matched in document order so an inline tab
|
|
12
|
+
// does not get hoisted out of position by a tags-then-text pass.
|
|
13
|
+
const RUN_CONTENT = /<w:t(?:\s[^>]*)?>([\s\S]*?)<\/w:t>|<w:(tab|br|cr)\s*\/>/g;
|
|
14
|
+
function paragraphText(inner) {
|
|
15
|
+
let text = '';
|
|
16
|
+
for (let m = RUN_CONTENT.exec(inner); m !== null; m = RUN_CONTENT.exec(inner)) {
|
|
17
|
+
if (m[1] !== undefined)
|
|
18
|
+
text += decodeXmlEntities(m[1]);
|
|
19
|
+
else
|
|
20
|
+
text += m[2] === 'tab' ? '\t' : '\n';
|
|
21
|
+
}
|
|
22
|
+
RUN_CONTENT.lastIndex = 0;
|
|
23
|
+
return text;
|
|
24
|
+
}
|
|
25
|
+
/** Prefix a paragraph according to its `<w:pStyle>`, if it has a known one. */
|
|
26
|
+
function styledParagraph(inner) {
|
|
27
|
+
const text = paragraphText(inner);
|
|
28
|
+
if (text === '')
|
|
29
|
+
return '';
|
|
30
|
+
const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? '';
|
|
31
|
+
const heading = /^Heading(\d)$/.exec(style);
|
|
32
|
+
if (heading)
|
|
33
|
+
return `${'#'.repeat(Math.min(Number(heading[1]), 6))} ${text}`;
|
|
34
|
+
if (style === 'Title' || style === 'Subtitle')
|
|
35
|
+
return `# ${text}`;
|
|
36
|
+
if (style === 'ListParagraph')
|
|
37
|
+
return `- ${text}`;
|
|
38
|
+
return text;
|
|
39
|
+
}
|
|
40
|
+
function tableText(inner) {
|
|
41
|
+
const rows = [];
|
|
42
|
+
for (const tr of elements(inner, 'w:tr')) {
|
|
43
|
+
const cells = [];
|
|
44
|
+
for (const tc of elements(tr.inner, 'w:tc')) {
|
|
45
|
+
// A cell holds paragraphs; keep it on one line so the row stays a row.
|
|
46
|
+
const parts = [];
|
|
47
|
+
for (const p of elements(tc.inner, 'w:p')) {
|
|
48
|
+
const text = paragraphText(p.inner);
|
|
49
|
+
if (text !== '')
|
|
50
|
+
parts.push(text);
|
|
51
|
+
}
|
|
52
|
+
cells.push(parts.join(' '));
|
|
53
|
+
}
|
|
54
|
+
rows.push(`| ${cells.join(' | ')} |`);
|
|
55
|
+
}
|
|
56
|
+
return rows.join('\n');
|
|
57
|
+
}
|
|
58
|
+
// Top-level block scan: a table swallows its own paragraphs (the match spans
|
|
59
|
+
// them), so the alternation yields tables and stray paragraphs in order without
|
|
60
|
+
// emitting a table's cell text twice.
|
|
61
|
+
// The paragraph arm mirrors `elements()`: a lazy attribute run that does not
|
|
62
|
+
// eat a self-closing slash, so `<w:p w14:paraId="…"/>` cannot swallow the
|
|
63
|
+
// following paragraph.
|
|
64
|
+
const BLOCK = /<w:tbl(?:\s[^>]*?)?>[\s\S]*?<\/w:tbl>|<w:p(?:\s[^>]*?)?(?:\s*\/>|>([\s\S]*?)<\/w:p>)/g;
|
|
65
|
+
/** Extract the text of a .docx document part. */
|
|
66
|
+
export async function extractDocx(bytes) {
|
|
67
|
+
const zip = await readZip(bytes);
|
|
68
|
+
const path = findPart(zip, 'word/document.xml', 'document.xml');
|
|
69
|
+
if (!path)
|
|
70
|
+
throw new Error('no document part found in the .docx archive');
|
|
71
|
+
/* v8 ignore next -- findPart only returns a name the archive contains, so readText cannot be null here */
|
|
72
|
+
const xml = (await zip.readText(path)) ?? '';
|
|
73
|
+
const blocks = [];
|
|
74
|
+
for (let m = BLOCK.exec(xml); m !== null; m = BLOCK.exec(xml)) {
|
|
75
|
+
// Group 1 is the paragraph's inner XML — undefined for a table match and
|
|
76
|
+
// for a self-closing (empty) paragraph.
|
|
77
|
+
const text = m[0].startsWith('<w:tbl') ? tableText(m[0]) : styledParagraph(m[1] ?? '');
|
|
78
|
+
if (text !== '')
|
|
79
|
+
blocks.push(text);
|
|
80
|
+
}
|
|
81
|
+
BLOCK.lastIndex = 0;
|
|
82
|
+
return { kind: 'document', text: blocks.join('\n\n') };
|
|
83
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The delivery ladder's extraction rung.
|
|
2
|
+
//
|
|
3
|
+
// `ofw_download_attachment` fetches bytes successfully and then has to hand the
|
|
4
|
+
// caller something it can actually read. A host renders four image types
|
|
5
|
+
// inline and rejects every other embedded resource outright ("Resources of
|
|
6
|
+
// type 'application/vnd.…sheet' are not currently supported"), so for a
|
|
7
|
+
// spreadsheet, PDF, Word or PowerPoint attachment the bytes are delivered and
|
|
8
|
+
// unreadable at the same time. Rendering is a display concern; content is a
|
|
9
|
+
// data concern, and this module is what keeps the second from being decided by
|
|
10
|
+
// the first: whatever the host can draw, the FILE's text comes back as text.
|
|
11
|
+
import { extractXlsx, extractDelimited } from './spreadsheet.js';
|
|
12
|
+
import { extractDocx } from './document.js';
|
|
13
|
+
import { extractPptx } from './presentation.js';
|
|
14
|
+
import { extractPdf } from './pdf.js';
|
|
15
|
+
const MIME_KINDS = {
|
|
16
|
+
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
|
|
17
|
+
'application/vnd.ms-excel.sheet.macroenabled.12': 'xlsx',
|
|
18
|
+
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
|
|
19
|
+
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
|
|
20
|
+
'application/pdf': 'pdf',
|
|
21
|
+
'text/csv': 'csv',
|
|
22
|
+
'text/tab-separated-values': 'tsv',
|
|
23
|
+
'application/json': 'text',
|
|
24
|
+
'application/xml': 'text',
|
|
25
|
+
'application/xhtml+xml': 'text',
|
|
26
|
+
'application/javascript': 'text',
|
|
27
|
+
'application/x-yaml': 'text',
|
|
28
|
+
};
|
|
29
|
+
const EXT_KINDS = {
|
|
30
|
+
'.xlsx': 'xlsx', '.xlsm': 'xlsx',
|
|
31
|
+
'.docx': 'docx',
|
|
32
|
+
'.pptx': 'pptx',
|
|
33
|
+
'.pdf': 'pdf',
|
|
34
|
+
'.csv': 'csv',
|
|
35
|
+
'.tsv': 'tsv', '.tab': 'tsv',
|
|
36
|
+
'.txt': 'text', '.md': 'text', '.json': 'text', '.xml': 'text',
|
|
37
|
+
'.html': 'text', '.htm': 'text', '.log': 'text', '.yaml': 'text', '.yml': 'text',
|
|
38
|
+
'.ics': 'text', '.vcf': 'text', '.srt': 'text',
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Which extractor handles this attachment, by MIME then by extension. The
|
|
42
|
+
* extension is a real fallback, not a formality: OFW serves binaries with a
|
|
43
|
+
* `charset` bolted on and sometimes with no useful type at all.
|
|
44
|
+
*/
|
|
45
|
+
export function extractKindFor(mimeType, fileName) {
|
|
46
|
+
const byMime = MIME_KINDS[mimeType];
|
|
47
|
+
if (byMime)
|
|
48
|
+
return byMime;
|
|
49
|
+
const dot = fileName.lastIndexOf('.');
|
|
50
|
+
const byExt = dot === -1 ? undefined : EXT_KINDS[fileName.slice(dot).toLowerCase()];
|
|
51
|
+
if (byExt)
|
|
52
|
+
return byExt;
|
|
53
|
+
// Any other text/* type (text/plain, text/markdown, text/html, …) is text.
|
|
54
|
+
return mimeType.startsWith('text/') ? 'text' : null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Build a part selector from a `"1-3,5"` / `"Summary,2026"` spec. Numbers are
|
|
58
|
+
* 1-based positions; anything else matches a sheet or slide/page label. An
|
|
59
|
+
* empty spec selects everything rather than nothing — a filter that silently
|
|
60
|
+
* excluded all content would look exactly like an empty file.
|
|
61
|
+
*/
|
|
62
|
+
export function parsePartSpec(spec) {
|
|
63
|
+
const ranges = [];
|
|
64
|
+
const names = new Set();
|
|
65
|
+
for (const token of spec.split(',').map((t) => t.trim()).filter(Boolean)) {
|
|
66
|
+
const range = /^(\d+)\s*-\s*(\d+)$/.exec(token);
|
|
67
|
+
if (range) {
|
|
68
|
+
ranges.push([Number(range[1]), Number(range[2])]);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// A bare number is a position AND a possible name: spreadsheet tabs are
|
|
72
|
+
// routinely named for a year ("2026"), and reading that token as a position
|
|
73
|
+
// only would make the obvious `parts: "2026"` select nothing at all.
|
|
74
|
+
if (/^\d+$/.test(token))
|
|
75
|
+
ranges.push([Number(token), Number(token)]);
|
|
76
|
+
names.add(token.toLowerCase());
|
|
77
|
+
}
|
|
78
|
+
if (ranges.length === 0 && names.size === 0)
|
|
79
|
+
return () => true;
|
|
80
|
+
return (index, name) => ranges.some(([from, to]) => index + 1 >= from && index + 1 <= to)
|
|
81
|
+
|| names.has(name.toLowerCase());
|
|
82
|
+
}
|
|
83
|
+
/** Cut `text` to `budget` characters on a line boundary where possible. */
|
|
84
|
+
function clip(text, budget) {
|
|
85
|
+
const cut = text.slice(0, budget);
|
|
86
|
+
const lastBreak = cut.lastIndexOf('\n');
|
|
87
|
+
return lastBreak > 0 ? cut.slice(0, lastBreak) : cut;
|
|
88
|
+
}
|
|
89
|
+
function omissionNote(label) {
|
|
90
|
+
return `${label} (omitted: response character budget)`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Trim an extraction to `maxChars`, recording exactly what was dropped. A
|
|
94
|
+
* truncated payload that does not say so is worse than a short one: the caller
|
|
95
|
+
* reads a partial custody schedule as the whole schedule.
|
|
96
|
+
*/
|
|
97
|
+
export function applyCharBudget(extracted, maxChars) {
|
|
98
|
+
switch (extracted.kind) {
|
|
99
|
+
case 'text':
|
|
100
|
+
case 'document': {
|
|
101
|
+
if (extracted.text.length <= maxChars)
|
|
102
|
+
return extracted;
|
|
103
|
+
return { ...extracted, text: clip(extracted.text, maxChars), truncated: true };
|
|
104
|
+
}
|
|
105
|
+
case 'spreadsheet': {
|
|
106
|
+
const sheets = [];
|
|
107
|
+
const omitted = [...(extracted.omitted ?? [])];
|
|
108
|
+
let budget = maxChars;
|
|
109
|
+
let truncated = extracted.truncated ?? false;
|
|
110
|
+
for (const sheet of extracted.sheets) {
|
|
111
|
+
if (budget <= 0) {
|
|
112
|
+
omitted.push(omissionNote(sheet.name));
|
|
113
|
+
truncated = true;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (sheet.csv.length <= budget) {
|
|
117
|
+
sheets.push(sheet);
|
|
118
|
+
budget -= sheet.csv.length;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const csv = clip(sheet.csv, budget);
|
|
122
|
+
sheets.push({ ...sheet, csv, rows: csv.split('\n').length, truncated: true });
|
|
123
|
+
budget = 0;
|
|
124
|
+
truncated = true;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...extracted, sheets, truncated,
|
|
128
|
+
...(omitted.length ? { omitted } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
case 'presentation': {
|
|
132
|
+
const slides = [];
|
|
133
|
+
const omitted = [...(extracted.omitted ?? [])];
|
|
134
|
+
let budget = maxChars;
|
|
135
|
+
let truncated = extracted.truncated ?? false;
|
|
136
|
+
for (const slide of extracted.slides) {
|
|
137
|
+
const size = slide.text.length + (slide.notes?.length ?? 0);
|
|
138
|
+
if (budget <= 0) {
|
|
139
|
+
omitted.push(omissionNote(`slide ${slide.number}`));
|
|
140
|
+
truncated = true;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (size <= budget) {
|
|
144
|
+
slides.push(slide);
|
|
145
|
+
budget -= size;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
slides.push({ ...slide, text: clip(slide.text, budget), notes: undefined });
|
|
149
|
+
budget = 0;
|
|
150
|
+
truncated = true;
|
|
151
|
+
}
|
|
152
|
+
return { ...extracted, slides, truncated, ...(omitted.length ? { omitted } : {}) };
|
|
153
|
+
}
|
|
154
|
+
case 'pdf': {
|
|
155
|
+
const pages = [];
|
|
156
|
+
const omitted = [...(extracted.omitted ?? [])];
|
|
157
|
+
let budget = maxChars;
|
|
158
|
+
let truncated = extracted.truncated ?? false;
|
|
159
|
+
for (const page of extracted.pages) {
|
|
160
|
+
if (budget <= 0) {
|
|
161
|
+
omitted.push(omissionNote(`page ${page.number}`));
|
|
162
|
+
truncated = true;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (page.text.length <= budget) {
|
|
166
|
+
pages.push(page);
|
|
167
|
+
budget -= page.text.length;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
pages.push({ ...page, text: clip(page.text, budget) });
|
|
171
|
+
budget = 0;
|
|
172
|
+
truncated = true;
|
|
173
|
+
}
|
|
174
|
+
return { ...extracted, pages, truncated, ...(omitted.length ? { omitted } : {}) };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Default response budget: roughly 12k tokens of extracted text. */
|
|
179
|
+
export const DEFAULT_MAX_CHARS = 50_000;
|
|
180
|
+
function decodeText(bytes) {
|
|
181
|
+
const text = bytes.toString('utf8');
|
|
182
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Extract readable content from an attachment, or return null when the format
|
|
186
|
+
* has no extractor (a .zip, a .heic, an unknown binary) and the caller should
|
|
187
|
+
* fall through to the next rung of the delivery ladder.
|
|
188
|
+
*
|
|
189
|
+
* Throws when a format that SHOULD be extractable cannot be read, so the
|
|
190
|
+
* failure can be reported by name rather than swallowed.
|
|
191
|
+
*/
|
|
192
|
+
export async function extractAttachment(bytes, mimeType, fileName, opts = {}) {
|
|
193
|
+
const kind = extractKindFor(mimeType, fileName);
|
|
194
|
+
if (kind === null)
|
|
195
|
+
return null;
|
|
196
|
+
const select = opts.parts === undefined ? undefined : parsePartSpec(opts.parts);
|
|
197
|
+
let extracted;
|
|
198
|
+
switch (kind) {
|
|
199
|
+
case 'xlsx':
|
|
200
|
+
extracted = await extractXlsx(bytes, { select });
|
|
201
|
+
break;
|
|
202
|
+
case 'csv':
|
|
203
|
+
extracted = extractDelimited(decodeText(bytes), fileName, ',');
|
|
204
|
+
break;
|
|
205
|
+
case 'tsv':
|
|
206
|
+
extracted = extractDelimited(decodeText(bytes), fileName, '\t');
|
|
207
|
+
break;
|
|
208
|
+
case 'docx':
|
|
209
|
+
extracted = await extractDocx(bytes);
|
|
210
|
+
break;
|
|
211
|
+
case 'pptx':
|
|
212
|
+
extracted = await extractPptx(bytes, { select });
|
|
213
|
+
break;
|
|
214
|
+
case 'pdf':
|
|
215
|
+
extracted = await extractPdf(bytes, { select });
|
|
216
|
+
break;
|
|
217
|
+
case 'text':
|
|
218
|
+
extracted = { kind: 'text', text: decodeText(bytes) };
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
return applyCharBudget(extracted, opts.maxChars ?? DEFAULT_MAX_CHARS);
|
|
222
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Bounded decompression, shared by the ZIP reader and the PDF stream decoder.
|
|
2
|
+
//
|
|
3
|
+
// Both formats let the FILE state how big a member expands to, and both are
|
|
4
|
+
// read from co-parent-supplied attachments. A declared size is therefore a
|
|
5
|
+
// hint, never a guarantee: a ZIP central directory can claim 1 KB in front of a
|
|
6
|
+
// member that expands to a gigabyte, and a PDF stream dictionary declares
|
|
7
|
+
// nothing about its inflated length at all. Checking the declared number before
|
|
8
|
+
// inflating rejects an HONEST oversized member cheaply — it does nothing about
|
|
9
|
+
// a lying one.
|
|
10
|
+
//
|
|
11
|
+
// So the real cap is enforced on the bytes as they actually arrive: read the
|
|
12
|
+
// decompressed stream chunk by chunk, and abort the moment the running total
|
|
13
|
+
// passes the limit. Peak memory is then bounded by the limit rather than by
|
|
14
|
+
// whatever the file felt like claiming.
|
|
15
|
+
/** 32 MiB. Sized to fit comfortably inside the Worker's memory budget. */
|
|
16
|
+
export const MAX_DECOMPRESSED_BYTES = 32 * 1024 * 1024;
|
|
17
|
+
/**
|
|
18
|
+
* Thrown when decompression is aborted for exceeding its cap. Distinct from a
|
|
19
|
+
* decode failure so callers can tell "this file is hostile or absurd" from
|
|
20
|
+
* "this stream is corrupt" — the first deserves to be reported, the second is
|
|
21
|
+
* routinely survivable.
|
|
22
|
+
*/
|
|
23
|
+
export class DecompressionLimitError extends Error {
|
|
24
|
+
constructor(label, limit) {
|
|
25
|
+
super(`${label} expands past the ${limit}-byte decompression cap`);
|
|
26
|
+
this.name = 'DecompressionLimitError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Inflate `data`, aborting if the OUTPUT exceeds `limit` bytes.
|
|
31
|
+
*
|
|
32
|
+
* `deflate-raw` is the ZIP member format; `deflate` is the zlib-wrapped form a
|
|
33
|
+
* PDF `/FlateDecode` stream uses. Both go through the WHATWG
|
|
34
|
+
* `DecompressionStream` so this runs unchanged on Node and workerd.
|
|
35
|
+
*/
|
|
36
|
+
export async function inflateBounded(data, format, limit, label) {
|
|
37
|
+
const stream = new Blob([data]).stream()
|
|
38
|
+
.pipeThrough(new DecompressionStream(format));
|
|
39
|
+
const reader = stream.getReader();
|
|
40
|
+
const chunks = [];
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const { done, value } = await reader.read();
|
|
44
|
+
if (done)
|
|
45
|
+
break;
|
|
46
|
+
total += value.length;
|
|
47
|
+
if (total > limit) {
|
|
48
|
+
// Stop pulling: the rest of the payload is never allocated.
|
|
49
|
+
await reader.cancel();
|
|
50
|
+
throw new DecompressionLimitError(label, limit);
|
|
51
|
+
}
|
|
52
|
+
chunks.push(value);
|
|
53
|
+
}
|
|
54
|
+
return Buffer.concat(chunks);
|
|
55
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Shared OOXML plumbing: part-path resolution and relationship lookup.
|
|
2
|
+
//
|
|
3
|
+
// Every OOXML format points from one part to another through a `_rels` file
|
|
4
|
+
// (`r:id="rId3"` → `Target="worksheets/sheet3.xml"`), and the target is
|
|
5
|
+
// relative to the REFERRING part's directory. Guessing the target from the id
|
|
6
|
+
// number instead works right up until a document has been edited enough for the
|
|
7
|
+
// numbering to drift, which is why this indirection is followed properly.
|
|
8
|
+
import { elements, attr } from './xml.js';
|
|
9
|
+
/** Resolve a relationship target against the referring part's directory. */
|
|
10
|
+
export function resolvePartPath(baseDir, target) {
|
|
11
|
+
// A leading "/" means package-absolute.
|
|
12
|
+
if (target.startsWith('/'))
|
|
13
|
+
return target.slice(1);
|
|
14
|
+
const segments = (baseDir + target).split('/');
|
|
15
|
+
const out = [];
|
|
16
|
+
for (const segment of segments) {
|
|
17
|
+
if (segment === '.' || segment === '')
|
|
18
|
+
continue;
|
|
19
|
+
if (segment === '..')
|
|
20
|
+
out.pop();
|
|
21
|
+
else
|
|
22
|
+
out.push(segment);
|
|
23
|
+
}
|
|
24
|
+
return out.join('/');
|
|
25
|
+
}
|
|
26
|
+
/** The directory of a part path, with a trailing slash (`""` at the root). */
|
|
27
|
+
export function dirOf(partPath) {
|
|
28
|
+
const i = partPath.lastIndexOf('/');
|
|
29
|
+
return i === -1 ? '' : partPath.slice(0, i + 1);
|
|
30
|
+
}
|
|
31
|
+
/** Read the `_rels` companion of `partPath`. Empty when the part has none. */
|
|
32
|
+
export async function readRels(zip, partPath) {
|
|
33
|
+
const dir = dirOf(partPath);
|
|
34
|
+
const base = partPath.slice(dir.length);
|
|
35
|
+
const xml = await zip.readText(`${dir}_rels/${base}.rels`);
|
|
36
|
+
if (!xml)
|
|
37
|
+
return [];
|
|
38
|
+
const rels = [];
|
|
39
|
+
for (const el of elements(xml, 'Relationship')) {
|
|
40
|
+
const id = attr(el.attrs, 'Id');
|
|
41
|
+
const target = attr(el.attrs, 'Target');
|
|
42
|
+
/* v8 ignore next -- a Relationship without Id/Target is malformed OOXML; skipping keeps one bad entry from failing the whole read */
|
|
43
|
+
if (!id || !target)
|
|
44
|
+
continue;
|
|
45
|
+
rels.push({ id, target: resolvePartPath(dir, target), type: attr(el.attrs, 'Type') ?? '' });
|
|
46
|
+
}
|
|
47
|
+
return rels;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Locate a top-level part: the conventional path when present, otherwise the
|
|
51
|
+
* first entry whose name ends with the same file name. Producers other than
|
|
52
|
+
* Microsoft's occasionally use a different directory.
|
|
53
|
+
*/
|
|
54
|
+
export function findPart(zip, conventional, fileName) {
|
|
55
|
+
if (zip.has(conventional))
|
|
56
|
+
return conventional;
|
|
57
|
+
return zip.names().find((n) => n.endsWith(`/${fileName}`) || n === fileName) ?? null;
|
|
58
|
+
}
|