diff --git a/services/clsi/app.js b/services/clsi/app.js index 231e72dd86..286aa4d120 100644 --- a/services/clsi/app.js +++ b/services/clsi/app.js @@ -150,6 +150,10 @@ app.post( FileUploadMiddleware.multerMiddleware, ConversionController.convertPDFToJPEG ) +app.get( + '/project/:project_id/user/:user_id/build/:build_id/thumbnail', + ConversionController.thumbnailFromBuild +) if (process.env.NODE_ENV === 'development' && global.__coverage__) { app.get('/coverage', (req, res) => { diff --git a/services/clsi/app/js/ConversionController.js b/services/clsi/app/js/ConversionController.js index 1e62cd9873..e208498b63 100644 --- a/services/clsi/app/js/ConversionController.js +++ b/services/clsi/app/js/ConversionController.js @@ -251,8 +251,58 @@ async function convertProjectToDocument(req, res) { } } +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) + + const compileName = userId ? `${projectId}-${userId}` : projectId + const pdfPath = Path.join( + Settings.path.outputDir, + compileName, + OutputCacheManager.CACHE_SUBDIR, + buildId, + 'output.pdf' + ) + + try { + await fs.access(pdfPath) + } catch { + 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) + } + + try { + const jpegStat = await fs.stat(jpegPath) + res.setHeader('Content-Type', 'image/jpeg') + res.setHeader('Content-Length', jpegStat.size) + res.setHeader('Cache-Control', 'public, max-age=86400') + res.setHeader('X-Content-Type-Options', 'nosniff') + const readStream = fsSync.createReadStream(jpegPath) + await pipeline(readStream, res) + } finally { + await fs + .rm(Path.dirname(jpegPath), { recursive: true, force: true }) + .catch(() => {}) + } +} + export default { convertDocumentToLaTeX: expressify(convertDocumentToLaTeX), convertProjectToDocument: expressify(convertProjectToDocument), convertPDFToJPEG: expressify(convertPDFToJPEG), + thumbnailFromBuild: expressify(thumbnailFromBuild), } diff --git a/services/web/app/src/Features/Compile/CompileController.mjs b/services/web/app/src/Features/Compile/CompileController.mjs index aed625f2b9..4ae2647cea 100644 --- a/services/web/app/src/Features/Compile/CompileController.mjs +++ b/services/web/app/src/Features/Compile/CompileController.mjs @@ -20,6 +20,7 @@ import { fetchStreamWithResponse, RequestFailedError, } from '@overleaf/fetch-utils' +import ThumbnailManager from './ThumbnailManager.mjs' import Features from '../../infrastructure/Features.mjs' import ClsiCacheController from './ClsiCacheController.mjs' import { prepareZipAttachment } from '../../infrastructure/Response.mjs' @@ -321,6 +322,10 @@ const _CompileController = { .catch(err => logger.warn({ err, projectId }, 'failed to update quartoFlavor') ) + + ThumbnailManager.generateAndCacheThumbnail(projectId, userId, buildId).catch( + err => logger.warn({ err, projectId }, 'thumbnail cache error') + ) } res.json({ @@ -346,6 +351,15 @@ const _CompileController = { res.sendStatus(200) }, + async getProjectThumbnail(req, res) { + const projectId = req.params.Project_id + const jpeg = await ThumbnailManager.getCachedThumbnail(projectId) + if (!jpeg) return res.sendStatus(404) + res.setHeader('Content-Type', 'image/jpeg') + res.setHeader('Cache-Control', 'public, max-age=60') + res.send(jpeg) + }, + // Used for submissions through the public API async compileSubmission(req, res) { res.setTimeout(COMPILE_TIMEOUT_MS) @@ -822,6 +836,7 @@ const CompileController = { proxySyncPdf: expressify(_CompileController.proxySyncPdf), proxySyncCode: expressify(_CompileController.proxySyncCode), wordCount: expressify(_CompileController.wordCount), + getProjectThumbnail: expressify(_CompileController.getProjectThumbnail), _getSafeProjectName: _CompileController._getSafeProjectName, _getSplitTestOptions, diff --git a/services/web/app/src/Features/Compile/ThumbnailManager.mjs b/services/web/app/src/Features/Compile/ThumbnailManager.mjs new file mode 100644 index 0000000000..2df4c6e491 --- /dev/null +++ b/services/web/app/src/Features/Compile/ThumbnailManager.mjs @@ -0,0 +1,63 @@ +import Settings from '@overleaf/settings' +import logger from '@overleaf/logger' +import RedisWrapper from '../../infrastructure/RedisWrapper.mjs' +import { fetchStreamWithResponse, RequestFailedError } from '@overleaf/fetch-utils' + +const rclient = RedisWrapper.client('web') +const THUMBNAIL_TTL = 60 * 60 * 24 * 90 // 90 days + +function redisKey(projectId) { + return `Thumbnail:${projectId}` +} + +function clsiThumbnailUrl(projectId, userId, buildId) { + return `${Settings.apis.clsi.url}/project/${projectId}/user/${userId}/build/${buildId}/thumbnail` +} + +async function generateAndCacheThumbnail(projectId, userId, buildId) { + if (!userId || !buildId) return + + const url = clsiThumbnailUrl(projectId, userId, buildId) + let stream + try { + ;({ stream } = await fetchStreamWithResponse(url)) + } catch (err) { + if (!(err instanceof RequestFailedError)) { + logger.warn( + { err, projectId, buildId }, + 'unexpected error fetching thumbnail from CLSI' + ) + } + return + } + + try { + const chunks = [] + for await (const chunk of stream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const jpegBuffer = Buffer.concat(chunks) + if (jpegBuffer.length === 0) return + await rclient.set( + redisKey(projectId), + jpegBuffer.toString('base64'), + 'EX', + THUMBNAIL_TTL + ) + } catch (err) { + logger.warn({ err, projectId }, 'failed to cache thumbnail in Redis') + } +} + +async function getCachedThumbnail(projectId) { + try { + const b64 = await rclient.get(redisKey(projectId)) + if (!b64) return null + return Buffer.from(b64, 'base64') + } catch (err) { + logger.warn({ err, projectId }, 'failed to get thumbnail from Redis') + return null + } +} + +export default { generateAndCacheThumbnail, getCachedThumbnail } diff --git a/services/web/app/src/router.mjs b/services/web/app/src/router.mjs index c67ab996cb..62b4a4b510 100644 --- a/services/web/app/src/router.mjs +++ b/services/web/app/src/router.mjs @@ -611,6 +611,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) { CompileController.stopCompile ) + webRouter.get( + '/project/:Project_id/thumbnail', + AuthorizationMiddleware.ensureUserCanReadProject, + CompileController.getProjectThumbnail + ) + webRouter.get( '/project/:Project_id/output/cached/output.overleaf.json', AsyncLocalStorage.middleware, diff --git a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx index 60dcc66ecd..0329f91bfe 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx @@ -74,6 +74,15 @@ const ProjectCard = memo(function ProjectCard({ translate="no" >