/** * apps/worker — files pipeline: metadata extraction step (TASK-0102 §4). * * Streaming where possible (the object is already on disk from the download * step): * * - mime — re-sniff from the object's first bytes (FINAL authority; * overrides the declared type when the two disagree). * - size — the S3 HEAD size (recorded by the finalize step). * - sha256 — streaming hash computed during download (on `ctx`). * - images — width/height/orientation via `sharp` (memory-capped via * `limitInputPixels`; undecodable → warning, still READY). * - video — duration (s) + codec via `ffprobe` (probe cap 30 s). * - PDF — page count via `pdfjs-dist` (page cap 5 000). * * A file is downloadable even without rich metadata — extraction failures * degrade to `metadata.pipeline.warnings[]` and the file still reaches READY * (only thumbnails are affected). */ import { readFile } from "node:fs/promises"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { getLogger } from "../../../logger.js"; import { getSharp } from "../../../lib/sharp.js"; import type { FilePipelineContext } from "../types.js"; const log = getLogger("files.metadata"); const pExecFile = promisify(execFile); /** Security caps (TASK-0102 §Security). */ const MAX_INPUT_PIXELS = 16 * 1024 * 1024; // 16 MP decode cap (sharp) const PDF_PAGE_CAP = 5_000; const FFPROBE_TIMEOUT_MS = 30_000; const PARSER_TIMEOUT_MS = 10_000; /** Run `fn` with a wall-timeout; resolve with `fallback` on timeout. */ async function withTimeout( fn: () => Promise, ms: number, fallback: T ): Promise { return await Promise.race([ fn(), new Promise((resolve) => setTimeout(() => resolve(fallback), ms).unref()) ]); } interface SharpMeta { width?: number; height?: number; orientation?: number; } /** Image dimensions + EXIF orientation via sharp (memory-capped). */ async function imageMeta(ctx: FilePipelineContext): Promise { const sharp = await getSharp(); // NOTE: `limitInputPixels` is a sharp CONSTRUCTOR option (memory cap), not a // chainable pipeline method — passing it on the instance is a TypeError. const res: SharpMeta | null = await withTimeout( () => sharp(ctx.temp.path, { limitInputPixels: MAX_INPUT_PIXELS }) .rotate() // apply EXIF orientation so width/height match what's rendered .metadata(), PARSER_TIMEOUT_MS, null ); if (res && res.width && res.height) { ctx.extracted.dimensions = { width: res.width, height: res.height, orientation: res.orientation ?? 1 }; } else { ctx.warnings.push("image: could not decode dimensions (sharp)"); } } /** Video/audio duration (s) + codec via ffprobe. */ async function mediaMeta(ctx: FilePipelineContext): Promise { // NOTE: `-show_entries stream=codec_type` is NOT a valid entry on some // ffprobe builds (8.x rejects it — "No match for section 'codec_type'"), // so we use the version-independent `-show_streams -show_format` (full // dumps) and read the fields we need. The 30 s execFile timeout (not a // `-read_intervals` cap) bounds a slow/huge probe. const out = await withTimeout( async () => (await pExecFile( "ffprobe", ["-v", "error", "-show_streams", "-show_format", "-of", "json", ctx.temp.path], { timeout: FFPROBE_TIMEOUT_MS, maxBuffer: 2 * 1024 * 1024 } )).stdout, FFPROBE_TIMEOUT_MS, "" ); if (!out) { ctx.warnings.push("media: ffprobe timed out or unavailable"); return; } try { const parsed = JSON.parse(out) as { format?: { duration?: string }; streams?: Array<{ codec_name?: string; codec_type?: string }>; }; const dur = parseFloat(parsed.format?.duration ?? "NaN"); const videoStream = (parsed.streams ?? []).find((s) => s.codec_type === "video"); const audioStream = (parsed.streams ?? []).find((s) => s.codec_type === "audio"); const codec = videoStream?.codec_name ?? audioStream?.codec_name ?? "unknown"; if (Number.isFinite(dur)) { ctx.extracted.media = { duration: dur, codec }; } else { ctx.warnings.push("media: ffprobe returned no duration"); } } catch (err) { ctx.warnings.push(`media: ffprobe output unparseable (${(err as Error).message})`); } } /** PDF page count via pdfjs-dist (page cap 5 000). */ async function pdfMeta(ctx: FilePipelineContext): Promise { type PdfDoc = import("pdfjs-dist").PDFDocumentProxy; const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs"); const data = new Uint8Array(await readFile(ctx.temp.path)); const loadingTask = getDocument({ data, useSystemFonts: true, disableFontFace: true, }); const doc: PdfDoc | null = await withTimeout( () => loadingTask.promise, PARSER_TIMEOUT_MS * 3, null ); if (!doc) { ctx.warnings.push("pdf: could not count pages (pdfjs timeout/undecodable)"); return; } ctx.extracted.pdf = { pages: doc.numPages }; if (doc.numPages > PDF_PAGE_CAP) { ctx.warnings.push(`pdf: page count ${doc.numPages} exceeds cap ${PDF_PAGE_CAP} — no rasterize`); } } /** Run the metadata step for the (re-sniffed) mime kind. */ export async function metadataStep(ctx: FilePipelineContext, sniffedMime: string): Promise { ctx.extracted.sniffedMime = sniffedMime; try { if (sniffedMime.startsWith("image/")) await imageMeta(ctx); else if (sniffedMime.startsWith("video/")) await mediaMeta(ctx); else if (sniffedMime === "application/pdf") await pdfMeta(ctx); else { // audio: ffprobe duration is still useful; other kinds: nothing to do. if (sniffedMime.startsWith("audio/")) await mediaMeta(ctx); } } catch (err) { // Extraction is best-effort: a parser crash is a WARNING, not a fatal // failure (the file is still downloadable — only rich metadata suffers). ctx.warnings.push(`metadata extraction failed: ${(err as Error).message}`); log.warn({ fileId: ctx.job.fileId, err: (err as Error).message }, "metadata extraction failed (non-fatal)"); } }