mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: read PDF attachments only as far as needed, with a time limit and no scripts
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { pdfText, setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
|
||||
import { extractText } from '@/lib/attachment-text';
|
||||
|
||||
// pdf.js is stood in for: each page is a list of text lines.
|
||||
|
||||
const calls = { pages: 0, sources: [] as unknown[], destroyed: 0 };
|
||||
|
||||
function fakePdf(pages: string[][], { hang = false } = {}): PdfLib {
|
||||
return {
|
||||
getDocument: (src) => {
|
||||
calls.sources.push(src);
|
||||
return {
|
||||
promise: hang ? new Promise(() => {}) : Promise.resolve({
|
||||
numPages: pages.length,
|
||||
getPage: async (n: number) => {
|
||||
calls.pages++;
|
||||
return { getTextContent: async () => ({ items: pages[n - 1].map((str, i, all) => ({ str, hasEOL: i < all.length - 1 })) }) };
|
||||
},
|
||||
}),
|
||||
destroy: async () => { calls.destroyed++; },
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const pdf = new TextEncoder().encode('%PDF-1.4');
|
||||
|
||||
afterEach(() => {
|
||||
setPdfLoader();
|
||||
calls.pages = 0;
|
||||
calls.sources.length = 0;
|
||||
calls.destroyed = 0;
|
||||
});
|
||||
|
||||
describe('pdfText', () => {
|
||||
it('reads a typed cover letter in front of a scan', async () => {
|
||||
setPdfLoader(async () => fakePdf([['Dear Alice, please find the signed contract attached.'], [''], [''], ['']]));
|
||||
const out = await extractText(pdf, { name: 'scan.pdf' });
|
||||
expect(out).toMatchObject({ status: 'ok', pages: 4 });
|
||||
expect(out.text).toContain('signed contract');
|
||||
});
|
||||
|
||||
it('stops reading pages once the budget is used up', async () => {
|
||||
setPdfLoader(async () => fakePdf(Array.from({ length: 50 }, (_, i) => [`Page ${i + 1}: ${'word '.repeat(200)}`])));
|
||||
const out = await pdfText(pdf, { budget: 2_500 });
|
||||
expect(out).toMatchObject({ status: 'ok', truncated: true, pages: 50 });
|
||||
expect(calls.pages).toBeLessThanOrEqual(4);
|
||||
expect(out.text.length).toBeLessThanOrEqual(2_500);
|
||||
});
|
||||
|
||||
it('never lets pdf.js evaluate scripts', async () => {
|
||||
setPdfLoader(async () => fakePdf([['Some text that is long enough to count']]));
|
||||
await pdfText(pdf);
|
||||
expect(calls.sources[0]).toMatchObject({ isEvalSupported: false });
|
||||
});
|
||||
|
||||
it('gives up after the timeout and stops pdf.js', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
setPdfLoader(async () => fakePdf([], { hang: true }));
|
||||
const pending = pdfText(pdf, { timeoutMs: 1_000 });
|
||||
await vi.advanceTimersByTimeAsync(1_001);
|
||||
expect(await pending).toMatchObject({ status: 'failed' });
|
||||
expect(calls.destroyed).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -78,7 +78,7 @@ async function read(bytes: Uint8Array, format: TextFormat, budget: number): Prom
|
||||
case 'vcf':
|
||||
return { status: 'ok', text: vcfText(decodeText(bytes)), truncated: false };
|
||||
case 'pdf':
|
||||
return pdfText(bytes);
|
||||
return pdfText(bytes, { budget });
|
||||
case 'docx': case 'xlsx': case 'pptx': case 'odt': case 'ods': case 'odp':
|
||||
return officeText(bytes, format, { budget });
|
||||
default:
|
||||
|
||||
+42
-18
@@ -4,17 +4,21 @@
|
||||
// refuses blob: workers).
|
||||
|
||||
import { liteMountedAssetUrl } from '@/lib/lite';
|
||||
import { MAX_PDF_PAGES, MIN_PDF_CHARS_PER_PAGE, type ExtractedText } from './types';
|
||||
import { MAX_PDF_PAGES, MAX_TEXT_CHARS, MIN_PDF_CHARS_PER_PAGE, TEXT_BUDGET_SLACK, type ExtractedText } from './types';
|
||||
import { TextSink } from './sink';
|
||||
|
||||
/** The part of pdf.js used here (tests pass a stand-in). */
|
||||
export interface PdfLib {
|
||||
getDocument(src: { data: Uint8Array }): { promise: Promise<PdfDoc>; destroy(): Promise<void> };
|
||||
getDocument(src: { data: Uint8Array; isEvalSupported?: boolean }): { promise: Promise<PdfDoc>; destroy(): Promise<void> };
|
||||
}
|
||||
interface PdfDoc {
|
||||
numPages: number;
|
||||
getPage(n: number): Promise<{ getTextContent(): Promise<{ items: unknown[] }> }>;
|
||||
}
|
||||
|
||||
/** Longest a PDF may take to read before the read gives up. */
|
||||
export const PDF_TIMEOUT_MS = 30_000;
|
||||
|
||||
async function loadPdfJs(): Promise<PdfLib> {
|
||||
const pdfjs = await import('pdfjs-dist');
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = liteMountedAssetUrl(new URL(
|
||||
@@ -44,36 +48,56 @@ function pageText(items: unknown[]): string {
|
||||
return out.replace(/[ \t]+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
|
||||
}
|
||||
|
||||
class TimedOut extends Error {}
|
||||
|
||||
export interface PdfOptions {
|
||||
/** Characters to read at most; reading stops at the page that fills it. */
|
||||
budget?: number;
|
||||
/** Longest the read may take (default PDF_TIMEOUT_MS). */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of at most MAX_PDF_PAGES pages, a "Page n" line before each.
|
||||
* no_text when the pages hold fewer than MIN_PDF_CHARS_PER_PAGE characters
|
||||
* on average (a scan); encrypted for a PDF that needs a password.
|
||||
* The text of at most MAX_PDF_PAGES pages, a "Page n" line before each,
|
||||
* stopping once `budget` characters are read. no_text when no page has
|
||||
* MIN_PDF_CHARS_PER_PAGE characters (a scan; a typed cover letter in front
|
||||
* of one is read); encrypted for a PDF that needs a password; failed after
|
||||
* `timeoutMs`. Scripts in the PDF are never evaluated.
|
||||
*/
|
||||
export async function pdfText(bytes: Uint8Array): Promise<Omit<ExtractedText, 'format'>> {
|
||||
export async function pdfText(bytes: Uint8Array, { budget = MAX_TEXT_CHARS + TEXT_BUDGET_SLACK, timeoutMs = PDF_TIMEOUT_MS }: PdfOptions = {}): Promise<Omit<ExtractedText, 'format'>> {
|
||||
const lib = await loader();
|
||||
// pdf.js may take over the buffer: give it a copy.
|
||||
const task = lib.getDocument({ data: bytes.slice() });
|
||||
try {
|
||||
const task = lib.getDocument({ data: bytes.slice(), isEvalSupported: false });
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new TimedOut()), timeoutMs); });
|
||||
const read = async (): Promise<Omit<ExtractedText, 'format'>> => {
|
||||
const doc = await task.promise;
|
||||
const pages = doc.numPages;
|
||||
const read = Math.min(pages, MAX_PDF_PAGES);
|
||||
const parts: string[] = [];
|
||||
let chars = 0;
|
||||
for (let n = 1; n <= read; n++) {
|
||||
const last = Math.min(pages, MAX_PDF_PAGES);
|
||||
const sink = new TextSink(budget);
|
||||
let readable = false;
|
||||
let n = 0;
|
||||
while (n < last && !sink.full) {
|
||||
n++;
|
||||
const page = await doc.getPage(n);
|
||||
const text = pageText((await page.getTextContent()).items);
|
||||
chars += text.replace(/\s/g, '').length;
|
||||
if (text) parts.push(read > 1 ? `Page ${n}\n${text}` : text);
|
||||
if (text.replace(/\s/g, '').length >= MIN_PDF_CHARS_PER_PAGE) readable = true;
|
||||
if (!text) continue;
|
||||
if (pages > 1) sink.line(`${n > 1 ? '\n' : ''}Page ${n}`);
|
||||
sink.line(text);
|
||||
}
|
||||
if (read === 0 || chars / read < MIN_PDF_CHARS_PER_PAGE) {
|
||||
return { status: 'no_text', text: '', truncated: false, pages };
|
||||
}
|
||||
return { status: 'ok', text: parts.join('\n\n'), truncated: pages > read, pages };
|
||||
if (!readable) return { status: 'no_text', text: '', truncated: false, pages };
|
||||
return { status: 'ok', text: sink.text(), truncated: sink.full || n < pages, pages };
|
||||
};
|
||||
try {
|
||||
return await Promise.race([read(), timeout]);
|
||||
} catch (err) {
|
||||
const name = (err as { name?: unknown } | null)?.name;
|
||||
if (name === 'PasswordException') return { status: 'encrypted', text: '', truncated: false };
|
||||
return { status: 'failed', text: '', truncated: false };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
// Also stops a read still running after the timeout.
|
||||
void task.destroy().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user