diff --git a/services/clsi/app/js/ConversionController.js b/services/clsi/app/js/ConversionController.js index e208498b63..53478aa8af 100644 --- a/services/clsi/app/js/ConversionController.js +++ b/services/clsi/app/js/ConversionController.js @@ -1,4 +1,7 @@ import crypto from 'node:crypto' +import { execFile } from 'node:child_process' +import os from 'node:os' +import { promisify } from 'node:util' import logger from '@overleaf/logger' import { expressify } from '@overleaf/promise-utils' import fs from 'node:fs/promises' @@ -16,6 +19,8 @@ import Settings from '@overleaf/settings' import Path from 'node:path' import { z } from '@overleaf/validation-tools' +const execFileAsync = promisify(execFile) + const CONVERSION_CONFIGS = { docx: { extension: 'docx' }, markdown: { extension: 'zip' }, @@ -251,9 +256,10 @@ async function convertProjectToDocument(req, res) { } } +// Generates a JPEG thumbnail of page 1 of the compiled output.pdf using +// pdftocairo (poppler-utils, installed in the CLSI base image). Does NOT +// require ENABLE_PDF_CONVERSIONS or the pdftocairo Docker image. async function thumbnailFromBuild(req, res) { - if (!Settings.enablePdfConversions) return res.sendStatus(404) - const { project_id: projectId, user_id: userId, build_id: buildId } = req.params if (!buildId?.match(OutputCacheManager.BUILD_REGEX)) return res.sendStatus(400) @@ -272,20 +278,27 @@ async function thumbnailFromBuild(req, res) { return res.sendStatus(404) } - const conversionId = crypto.randomUUID() - let jpegPath - try { - jpegPath = await ConversionManager.promises.convertPDFToJPEGWithLock( - conversionId, - pdfPath, - 'thumbnail' - ) - } catch (err) { - logger.warn({ err, projectId, buildId }, 'thumbnail conversion failed') - return res.sendStatus(500) - } + const tmpDir = await fs.mkdtemp(Path.join(os.tmpdir(), 'clsi-thumb-')) + const outputBase = Path.join(tmpDir, 'thumb') + const jpegPath = outputBase + '.jpg' try { + await execFileAsync( + 'pdftocairo', + [ + '-jpeg', + '-jpegopt', 'quality=50', + '-singlefile', + '-scale-to-x', '190', + '-scale-to-y', '-1', + '-f', '1', + '-l', '1', + pdfPath, + outputBase, + ], + { timeout: 30000 } + ) + const jpegStat = await fs.stat(jpegPath) res.setHeader('Content-Type', 'image/jpeg') res.setHeader('Content-Length', jpegStat.size) @@ -293,10 +306,11 @@ async function thumbnailFromBuild(req, res) { res.setHeader('X-Content-Type-Options', 'nosniff') const readStream = fsSync.createReadStream(jpegPath) await pipeline(readStream, res) + } catch (err) { + logger.warn({ err, projectId, buildId }, 'thumbnail generation failed') + if (!res.headersSent) res.sendStatus(500) } finally { - await fs - .rm(Path.dirname(jpegPath), { recursive: true, force: true }) - .catch(() => {}) + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}) } }