import { NotFoundError, ResourceGoneError } from '../Errors/Errors.js' import { fetchStreamWithResponse, RequestFailedError, } from '@overleaf/fetch-utils' import Path from 'node:path' import { pipeline } from 'node:stream/promises' import logger from '@overleaf/logger' import ClsiCacheManager from './ClsiCacheManager.mjs' import CompileController from './CompileController.mjs' import { expressify } from '@overleaf/promise-utils' import ClsiCacheHandler from './ClsiCacheHandler.mjs' import ProjectGetter from '../Project/ProjectGetter.mjs' import { MeteredStream } from '@overleaf/stream-utils' import Metrics from '@overleaf/metrics' /** * Download a file from a specific build on the clsi-cache. * * @param req * @param res * @return {Promise<*>} */ async function downloadFromCache(req, res) { const { Project_id: projectId, buildId, filename } = req.params return await _downloadFromCacheWithParams( req, res, projectId, buildId, filename ) } /** * Download a file from a specific build on the clsi-cache. * * @param req * @param res * @param projectId * @param buildId * @param filename * @param projectId * @param buildId * @param filename * @return {Promise<*>} */ async function _downloadFromCacheWithParams( req, res, projectId, buildId, filename ) { const userId = CompileController._getUserIdForCompile(req) const ac = new AbortController() let timer = setTimeout(() => ac.abort(), 10_000) let location, projectName try { ;[{ location }, { name: projectName }] = await Promise.all([ ClsiCacheHandler.getOutputFile( projectId, userId, buildId, filename, ac.signal ), ProjectGetter.promises.getProject(projectId, { name: 1 }), ]) } catch (err) { clearTimeout(timer) if (err instanceof NotFoundError) { // res.sendStatus() sends a description of the status as body. // Using res.status().end() avoids sending that fake body. return res.status(404).end() } else { throw err } } let stream, response try { ;({ stream, response } = await fetchStreamWithResponse(location, { signal: ac.signal, })) } finally { clearTimeout(timer) } if (req.destroyed) { // The client has disconnected already, avoid trying to write into the broken connection. stream.destroy(new Error('user aborted the request')) return } for (const key of ['Content-Length', 'Content-Type']) { if (response.headers.has(key)) res.setHeader(key, response.headers.get(key)) } const ext = Path.extname(filename) res.attachment( ext === '.pdf' ? `${CompileController._getSafeProjectName({ name: projectName })}.pdf` : filename ) // Downloads can take a while on a slow connection, increase timeouts to 10min const TEN_MINUTES_IN_MS = 10 * 60 * 1000 res.setTimeout(TEN_MINUTES_IN_MS) timer = setTimeout(() => ac.abort(), TEN_MINUTES_IN_MS) try { res.writeHead(response.status) await pipeline( stream, new MeteredStream(Metrics, 'clsi_cache_egress', { path: ClsiCacheHandler.getEgressLabel(filename), }), res ) } catch (err) { const reqAborted = Boolean(req.destroyed) const streamingStarted = Boolean(res.headersSent) if (!streamingStarted) { if (err instanceof RequestFailedError) { res.sendStatus(err.response.status) } else { res.sendStatus(500) } } if ( streamingStarted && reqAborted && (err.code === 'ERR_STREAM_PREMATURE_CLOSE' || err.code === 'ERR_STREAM_UNABLE_TO_PIPE') ) { // Ignore noisy spurious error return } logger.warn( { err, projectId, location, filename, reqAborted, streamingStarted, }, 'CLSI-cache proxy error' ) } finally { clearTimeout(timer) } } /** * Prepare a compile response from the clsi-cache. * * @param req * @param res * @return {Promise} */ async function getLatestBuildFromCache(req, res) { const { Project_id: projectId } = req.params const userId = CompileController._getUserIdForCompile(req) try { const { zone, outputFiles, compileGroup, clsiServerId, clsiCacheShard, options, stats, timings, } = await ClsiCacheManager.getLatestCompileResult(projectId, userId) let { pdfCachingMinChunkSize, pdfDownloadDomain } = await CompileController._getSplitTestOptions(req, res) pdfDownloadDomain += `/zone/${zone}` res.json({ fromCache: true, status: 'success', outputFiles, compileGroup, clsiServerId, clsiCacheShard, pdfDownloadDomain, pdfCachingMinChunkSize, options, stats, timings, }) } catch (err) { if (err instanceof NotFoundError) { res.sendStatus(404) } else if (err instanceof ResourceGoneError) { res.sendStatus(410) } else { throw err } } } export default { _downloadFromCacheWithParams, downloadFromCache: expressify(downloadFromCache), getLatestBuildFromCache: expressify(getLatestBuildFromCache), }