mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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>
309 lines
9.9 KiB
TypeScript
309 lines
9.9 KiB
TypeScript
/**
|
|
* @fileoverview Shared disk cache for expensive Office document previews.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import fs from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, dirname, extname, join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { promisify } from 'node:util';
|
|
import { runWithConversionLimit } from './document-conversion-limiter.js';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const OFFICE_CONVERSION_TIMEOUT_MS = 5 * 60_000;
|
|
const DOCUMENT_PREVIEW_CACHE_DIR = join(tmpdir(), 'codeman-document-preview-cache');
|
|
/**
|
|
* Cap on persistent converted-PDF files kept in DOCUMENT_PREVIEW_CACHE_DIR.
|
|
* The cache key embeds the source mtime, so every edit to a doc orphans its
|
|
* prior PDF; without a cap the dir grows unbounded across long-running sessions.
|
|
* Override with CODEMAN_MAX_PREVIEW_CACHE_FILES (clamped to >= 1).
|
|
*/
|
|
const MAX_PREVIEW_CACHE_FILES = (() => {
|
|
const raw = Number(process.env.CODEMAN_MAX_PREVIEW_CACHE_FILES);
|
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 100;
|
|
})();
|
|
function buildWordExportPdfScript(sourcePath: string, outputPath: string): string {
|
|
return `
|
|
$ErrorActionPreference = "Stop"
|
|
$source = ${toPowerShellSingleQuotedString(sourcePath)}
|
|
$output = ${toPowerShellSingleQuotedString(outputPath)}
|
|
$word = $null
|
|
$doc = $null
|
|
try {
|
|
$word = New-Object -ComObject Word.Application
|
|
$word.Visible = $false
|
|
$word.DisplayAlerts = 0
|
|
$doc = $word.Documents.Open($source)
|
|
$doc.ExportAsFixedFormat($output, 17)
|
|
} finally {
|
|
if ($null -ne $doc) {
|
|
$doc.Close($false) | Out-Null
|
|
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($doc) | Out-Null
|
|
}
|
|
if ($null -ne $word) {
|
|
$word.Quit() | Out-Null
|
|
[System.Runtime.InteropServices.Marshal]::ReleaseComObject($word) | Out-Null
|
|
}
|
|
[System.GC]::Collect()
|
|
[System.GC]::WaitForPendingFinalizers()
|
|
}
|
|
`.trim();
|
|
}
|
|
|
|
type OfficePreviewConverter = 'msword' | 'libreoffice';
|
|
|
|
const inFlightOfficeConversions = new Map<string, Promise<string | null>>();
|
|
|
|
export function clearDocumentPreviewCache(): void {
|
|
inFlightOfficeConversions.clear();
|
|
}
|
|
|
|
/**
|
|
* Best-effort LRU-ish eviction for the persistent converted-PDF cache: keeps at
|
|
* most MAX_PREVIEW_CACHE_FILES `*.pdf` files in `cacheDir`, deleting the oldest
|
|
* by mtime once over the cap. Never throws — a pruning failure must not fail the
|
|
* conversion that triggered it. Only `*.pdf` files are considered, so the
|
|
* transient `work-*` mkdtemp dirs are ignored.
|
|
*/
|
|
export async function pruneDocumentPreviewCache(cacheDir: string): Promise<void> {
|
|
try {
|
|
const entries = await fs.readdir(cacheDir);
|
|
const pdfs = entries.filter((name) => name.toLowerCase().endsWith('.pdf'));
|
|
if (pdfs.length <= MAX_PREVIEW_CACHE_FILES) return;
|
|
|
|
const stats = await Promise.all(
|
|
pdfs.map(async (name) => {
|
|
const fullPath = join(cacheDir, name);
|
|
try {
|
|
const stat = await fs.stat(fullPath);
|
|
return { fullPath, mtimeMs: stat.mtimeMs ?? 0 };
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
);
|
|
|
|
const sorted = stats.filter((s): s is { fullPath: string; mtimeMs: number } => s !== null);
|
|
sorted.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
const toRemove = sorted.slice(0, Math.max(0, sorted.length - MAX_PREVIEW_CACHE_FILES));
|
|
await Promise.all(toRemove.map((entry) => fs.rm(entry.fullPath, { force: true }).catch(() => {})));
|
|
} catch {
|
|
// Best-effort: pruning must never break a conversion.
|
|
}
|
|
}
|
|
|
|
export async function getOfficePreviewPdfPath(filePath: string, extension: string): Promise<string | null> {
|
|
const ext = extension.toLowerCase().replace(/^\./, '');
|
|
if (ext !== 'docx' && ext !== 'pptx') return null;
|
|
|
|
let sourceStat;
|
|
try {
|
|
sourceStat = await fs.stat(filePath);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
for (const converter of getOfficePreviewConverters(filePath, ext)) {
|
|
const cacheKey = createDocumentPreviewCacheKey(filePath, ext, sourceStat.size, sourceStat.mtimeMs ?? 0, converter);
|
|
const cachePath = getOfficePreviewCachePath(filePath, cacheKey, converter);
|
|
|
|
if (await fileExists(cachePath)) {
|
|
return cachePath;
|
|
}
|
|
|
|
const inFlightKey = `${converter}:${cacheKey}`;
|
|
const inFlight = inFlightOfficeConversions.get(inFlightKey);
|
|
if (inFlight) {
|
|
const converted = await inFlight;
|
|
if (converted) return converted;
|
|
continue;
|
|
}
|
|
|
|
const conversion =
|
|
converter === 'msword'
|
|
? convertWordDocumentToCachedPdf(filePath, cachePath)
|
|
: convertLibreOfficeDocumentToCachedPdf(filePath, cachePath);
|
|
inFlightOfficeConversions.set(inFlightKey, conversion);
|
|
try {
|
|
const converted = await conversion;
|
|
if (converted) return converted;
|
|
} finally {
|
|
inFlightOfficeConversions.delete(inFlightKey);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getOfficePreviewConverters(filePath: string, extension: string): OfficePreviewConverter[] {
|
|
if (extension === 'docx' && wslMountPathToWindowsPath(filePath)) {
|
|
return ['msword', 'libreoffice'];
|
|
}
|
|
return ['libreoffice'];
|
|
}
|
|
|
|
function createDocumentPreviewCacheKey(
|
|
filePath: string,
|
|
extension: string,
|
|
size: number,
|
|
mtimeMs: number,
|
|
converter: OfficePreviewConverter
|
|
): string {
|
|
return createHash('sha256')
|
|
.update(JSON.stringify({ cacheVersion: 2, converter, filePath, extension, size, mtimeMs }))
|
|
.digest('hex')
|
|
.slice(0, 32);
|
|
}
|
|
|
|
function getOfficePreviewCachePath(filePath: string, cacheKey: string, converter: OfficePreviewConverter): string {
|
|
if (converter === 'msword') {
|
|
const windowsCacheDir = getWindowsUserTempCacheDir(filePath);
|
|
if (windowsCacheDir) {
|
|
return join(windowsCacheDir, `${cacheKey}.pdf`);
|
|
}
|
|
}
|
|
|
|
return join(DOCUMENT_PREVIEW_CACHE_DIR, `${cacheKey}.pdf`);
|
|
}
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
const stat = await fs.stat(filePath);
|
|
return typeof stat.isFile !== 'function' || stat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function convertWordDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
|
const outputPath = wslMountPathToWindowsPath(cachePath);
|
|
if (!outputPath) return null;
|
|
|
|
let sourceCopyPath: string | undefined;
|
|
|
|
try {
|
|
await fs.mkdir(dirname(cachePath), { recursive: true });
|
|
sourceCopyPath = join(dirname(cachePath), `${basename(cachePath, '.pdf')}.docx`);
|
|
await fs.copyFile(filePath, sourceCopyPath);
|
|
|
|
const sourcePath = wslMountPathToWindowsPath(sourceCopyPath);
|
|
if (!sourcePath) return null;
|
|
|
|
await runWithConversionLimit(() =>
|
|
execFileAsync(
|
|
'powershell.exe',
|
|
[
|
|
'-NoProfile',
|
|
'-NonInteractive',
|
|
'-ExecutionPolicy',
|
|
'Bypass',
|
|
'-EncodedCommand',
|
|
encodePowerShellCommand(buildWordExportPdfScript(sourcePath, outputPath)),
|
|
],
|
|
{
|
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
|
maxBuffer: 1024 * 1024,
|
|
}
|
|
)
|
|
);
|
|
|
|
if (await fileExists(cachePath)) {
|
|
await pruneDocumentPreviewCache(dirname(cachePath));
|
|
return cachePath;
|
|
}
|
|
|
|
console.warn(`[DocumentPreviewCache] Microsoft Word did not produce PDF output for ${filePath}`);
|
|
return null;
|
|
} catch (err) {
|
|
console.warn(
|
|
`[DocumentPreviewCache] Failed to convert DOCX with Microsoft Word (${filePath}):`,
|
|
getCacheErrorMessage(err)
|
|
);
|
|
return null;
|
|
} finally {
|
|
if (sourceCopyPath) {
|
|
await fs.rm(sourceCopyPath, { force: true }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function convertLibreOfficeDocumentToCachedPdf(filePath: string, cachePath: string): Promise<string | null> {
|
|
let workDir: string | undefined;
|
|
try {
|
|
await fs.mkdir(DOCUMENT_PREVIEW_CACHE_DIR, { recursive: true });
|
|
const outDir = await fs.mkdtemp(join(DOCUMENT_PREVIEW_CACHE_DIR, 'work-'));
|
|
workDir = outDir;
|
|
const profileDir = join(outDir, 'profile');
|
|
await fs.mkdir(profileDir, { recursive: true });
|
|
|
|
await runWithConversionLimit(() =>
|
|
execFileAsync(
|
|
'soffice',
|
|
[
|
|
'--headless',
|
|
'--nologo',
|
|
'--nofirststartwizard',
|
|
`-env:UserInstallation=${pathToFileURL(profileDir).href}`,
|
|
'--convert-to',
|
|
'pdf',
|
|
'--outdir',
|
|
outDir,
|
|
filePath,
|
|
],
|
|
{
|
|
timeout: OFFICE_CONVERSION_TIMEOUT_MS,
|
|
maxBuffer: 1024 * 1024,
|
|
}
|
|
)
|
|
);
|
|
|
|
const converted = (await fs.readdir(outDir)).find((name) => name.toLowerCase().endsWith('.pdf'));
|
|
if (!converted) return null;
|
|
|
|
await fs.rename(join(workDir, converted), cachePath);
|
|
await pruneDocumentPreviewCache(DOCUMENT_PREVIEW_CACHE_DIR);
|
|
return cachePath;
|
|
} catch (err) {
|
|
console.warn(
|
|
`[DocumentPreviewCache] Failed to convert Office file to PDF (${filePath}):`,
|
|
getCacheErrorMessage(err)
|
|
);
|
|
return null;
|
|
} finally {
|
|
if (workDir) {
|
|
await fs.rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
}
|
|
|
|
function wslMountPathToWindowsPath(filePath: string): string | null {
|
|
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/(.+)$/);
|
|
if (!match) return null;
|
|
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
|
}
|
|
|
|
function getWindowsUserTempCacheDir(filePath: string): string | null {
|
|
const match = filePath.match(/^\/mnt\/([a-zA-Z])\/Users\/([^/]+)\//);
|
|
if (!match) return null;
|
|
return `/mnt/${match[1].toLowerCase()}/Users/${match[2]}/AppData/Local/Temp/codeman-document-preview-cache`;
|
|
}
|
|
|
|
function toPowerShellSingleQuotedString(value: string): string {
|
|
return `'${value.replace(/'/g, "''")}'`;
|
|
}
|
|
|
|
function encodePowerShellCommand(script: string): string {
|
|
return Buffer.from(script, 'utf16le').toString('base64');
|
|
}
|
|
|
|
export function getPreviewPdfDownloadName(fileName: string, extension: string): string {
|
|
return `${basename(fileName, extname(fileName) || `.${extension}`)}.pdf`;
|
|
}
|
|
|
|
function getCacheErrorMessage(err: unknown): string {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|