Files
Codeman/src/document-thumbnailer.ts
T
Claude (Codeman maintainer) 5fbe451c26 fix(attachments): harden document preview/thumbnail path (review of #120)
Follow-up hardening applied during review of PR #120, addressing the
adversarial multi-agent findings:

- fix(preview): render auto-detected (workspace, unregistered) DOCX/PPTX via
  the file-preview route and PDFs via file-raw in openFilePreview. Previously
  the Preview button fell through to file-content, dumping the binary Office/PDF
  bytes as mojibake, and the new file-preview route was unreachable dead code.
  (MAJOR: file-preview-route-unreachable-detected-office)

- perf(convert): add a global converter-concurrency limiter
  (document-conversion-limiter.ts) wrapping every pdftoppm / soffice /
  powershell spawn, so N simultaneous preview/thumbnail requests can no longer
  fork unbounded converter processes. Default cap 3, CODEMAN_MAX_DOCUMENT_CONVERSIONS.
  (MAJOR: no-converter-concurrency-limit)

- fix(cache): bound the converted-PDF disk cache with LRU-by-mtime eviction
  (pruneDocumentPreviewCache, default 100 files, CODEMAN_MAX_PREVIEW_CACHE_FILES),
  run after each successful conversion. Was unbounded.
  (MAJOR/MINOR: preview-cache-unbounded-disk-growth)

Tests: document-conversion-limiter.test.ts, document-preview-cache-eviction.test.ts,
and route coverage for the four new endpoints in
routes/file-routes-preview-thumbnail.test.ts (closes the missing-route-test gap).
Verified end-to-end against real pdftoppm (thumbnail render + concurrency cap).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:43:57 +02:00

89 lines
2.8 KiB
TypeScript

/**
* @fileoverview Best-effort first-page thumbnails for attachment cards.
*/
import { execFile } from 'node:child_process';
import fs from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { basename, extname, join } from 'node:path';
import { promisify } from 'node:util';
import { getOfficePreviewPdfPath } from './document-preview-cache.js';
import { runWithConversionLimit } from './document-conversion-limiter.js';
const execFileAsync = promisify(execFile);
const THUMBNAIL_CONVERSION_TIMEOUT_MS = 5 * 60_000;
export interface ThumbnailResult {
content: Buffer;
contentType: 'image/png';
}
export async function generateFirstPageThumbnail(filePath: string, extension: string): Promise<ThumbnailResult | null> {
const ext = extension.toLowerCase().replace(/^\./, '');
try {
await fs.stat(filePath);
if (ext === 'png') {
return { content: await fs.readFile(filePath), contentType: 'image/png' };
}
if (ext === 'pdf') {
return renderPdfFirstPage(filePath);
}
if (ext === 'docx' || ext === 'pptx') {
return renderOfficeFirstPage(filePath);
}
} catch (err) {
console.warn(`[Thumbnailer] Failed to generate ${ext} thumbnail for ${filePath}:`, getThumbnailErrorMessage(err));
return null;
}
return null;
}
async function renderOfficeFirstPage(filePath: string): Promise<ThumbnailResult | null> {
try {
const previewPdfPath = await getOfficePreviewPdfPath(filePath, extname(filePath).toLowerCase().replace(/^\./, ''));
if (!previewPdfPath) return null;
return await renderPdfFirstPage(previewPdfPath);
} catch (err) {
console.warn(
`[Thumbnailer] Failed to convert Office file to PDF for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
}
}
async function renderPdfFirstPage(filePath: string): Promise<ThumbnailResult | null> {
let previewDir: string | undefined;
try {
previewDir = await fs.mkdtemp(join(tmpdir(), 'codeman-thumb-pdf-'));
const prefix = join(previewDir, basename(filePath, extname(filePath)));
await runWithConversionLimit(() =>
execFileAsync('pdftoppm', ['-png', '-singlefile', '-f', '1', '-l', '1', '-scale-to', '520', filePath, prefix], {
timeout: THUMBNAIL_CONVERSION_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
})
);
const content = await fs.readFile(`${prefix}.png`);
return { content, contentType: 'image/png' };
} catch (err) {
console.warn(
`[Thumbnailer] Failed to render PDF first page for thumbnail (${filePath}):`,
getThumbnailErrorMessage(err)
);
return null;
} finally {
if (previewDir) {
await fs.rm(previewDir, { recursive: true, force: true }).catch(() => {});
}
}
}
function getThumbnailErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}