[WEB + CLSI] Download as docx file feature (#32851)
* using CLSI logic for fetching the project contents and skip the .zip export * Use unique conversion directory for project-to-docx export to avoid corrupting the shared compile directory when a compile runs concurrently * Remove X-Accel-Buffering header — not needed as CLSI does not run behind nginx * moving log before sending the data * Return CLSI stream directly instead of buffering to disk on web Previously convertProjectToDocx wrote the CLSI response to a temp file on disk, then the controller read it back to stream to the client. Now the stream is returned directly and piped to the response, avoiding unnecessary disk I/O on the web server. * Use href redirect for docx export instead of fetching blob into memory * making functions and files more generic so they can be used in future for other documents exports as well * adding export-docx split test * adding unit tests * adding cypress E2E test * format:fix * renaming the route to download from convert * adding new icon for export docx button * format:fix * remove unused showExportDocumentErrorToast export and adding guard against invalid Content-Length header from CLSI * format:fix * refactor(clsi): move promisify(parse) into RequestParser * refactor: generic conversion endpoint with type as route param * refactor: use type→extension map for validated conversion types * refactor(clsi): remove --standalone flag and fix rejection test * fixing the href in cypress test * renaming function * adding type to Metrics.inc * fix: rename exportProjectDocument, add WithLock wrapper and metrics type label * format:fix * fix: hide docx export from anonymous users and add WithLock wrapper * format fix * remove redundant Content-Length validation from DocumentConversionManager * format:fix * removing trailing icon GitOrigin-RevId: e9764fefac2c4b625d23be9e942ea4a8b283c70d
This commit is contained in:
@@ -1171,6 +1171,21 @@ function _finaliseRequest(projectId, options, project, docs, files) {
|
||||
}
|
||||
}
|
||||
|
||||
async function buildDocumentConversionRequest(projectId) {
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
compiler: 1,
|
||||
imageName: 1,
|
||||
'overleaf.history.id': 1,
|
||||
rootDoc_id: 1,
|
||||
rootFolder: 1,
|
||||
})
|
||||
if (project == null) {
|
||||
throw new Errors.NotFoundError(`project does not exist: ${projectId}`)
|
||||
}
|
||||
const projectStateHash = ClsiStateManager.computeHash(project, {})
|
||||
return _buildRequestFromMongo(projectId, {}, project, projectStateHash)
|
||||
}
|
||||
|
||||
async function wordCount(projectId, userId, file, limits, clsiserverid) {
|
||||
const { compileBackendClass, compileGroup } = limits
|
||||
const req = await _buildRequest(projectId, userId, limits)
|
||||
@@ -1297,5 +1312,6 @@ export default {
|
||||
getOutputFileStream,
|
||||
wordCount,
|
||||
syncTeX,
|
||||
buildDocumentConversionRequest,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,13 +5,55 @@ import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mj
|
||||
import { prepareZipAttachment } from '../../infrastructure/Response.mjs'
|
||||
import SessionManager from '../Authentication/SessionManager.mjs'
|
||||
import ProjectAuditLogHandler from '../Project/ProjectAuditLogHandler.mjs'
|
||||
import DocumentConversionManager from '../Uploads/DocumentConversionManager.mjs'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
|
||||
const SUPPORTED_CONVERSION_TYPES = new Map([['docx', 'docx']])
|
||||
|
||||
// Keep in sync with the logic for PDF files in CompileController
|
||||
function getSafeProjectName(project) {
|
||||
return project.name.replace(/[^\p{L}\p{Nd}]/gu, '_')
|
||||
}
|
||||
|
||||
async function exportProjectConversion(req, res) {
|
||||
const type = req.params.type
|
||||
const extension = SUPPORTED_CONVERSION_TYPES.get(type)
|
||||
if (!extension) {
|
||||
return res.sendStatus(400)
|
||||
}
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const projectId = req.params.Project_id
|
||||
Metrics.inc('document-exports', 1, { type })
|
||||
|
||||
const project = await ProjectGetter.promises.getProject(projectId, {
|
||||
name: true,
|
||||
})
|
||||
|
||||
const { stream, contentLength } =
|
||||
await DocumentConversionManager.promises.convertProjectToDocument(
|
||||
projectId,
|
||||
userId,
|
||||
type
|
||||
)
|
||||
|
||||
const safeFileName = getSafeProjectName(project)
|
||||
res.setHeader('Content-Length', contentLength)
|
||||
res.attachment(`${safeFileName}.${extension}`)
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('X-Accel-Buffering', 'no')
|
||||
ProjectAuditLogHandler.addEntryInBackground(
|
||||
projectId,
|
||||
`project-exported-${type}`,
|
||||
userId,
|
||||
req.ip
|
||||
)
|
||||
await pipeline(stream, res)
|
||||
}
|
||||
|
||||
export default {
|
||||
exportProjectConversion: expressify(exportProjectConversion),
|
||||
|
||||
downloadProject(req, res, next) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const projectId = req.params.Project_id
|
||||
|
||||
@@ -480,6 +480,7 @@ const _ProjectController = {
|
||||
'wf-fake-non-english-suggestions',
|
||||
'editor-tabs',
|
||||
'overleaf-code',
|
||||
'export-docx',
|
||||
].filter(Boolean)
|
||||
|
||||
const getUserValues = async userId =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import CompileManager from '../Compile/CompileManager.mjs'
|
||||
import ClsiManager from '../Compile/ClsiManager.mjs'
|
||||
import fs from 'node:fs'
|
||||
import fsPromises from 'node:fs/promises'
|
||||
import logger from '@overleaf/logger'
|
||||
@@ -38,14 +39,7 @@ async function convertDocxToLaTeXZipArchive(path, userId) {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
const contentLengthHeader = response.headers.get('Content-Length')
|
||||
if (contentLengthHeader == null) {
|
||||
logger.warn(
|
||||
'CLSI did not provide Content-Length header for converted document'
|
||||
)
|
||||
throw new OError('CLSI response missing Content-Length header')
|
||||
}
|
||||
const contentLength = parseInt(contentLengthHeader, 10)
|
||||
const contentLength = parseInt(response.headers.get('Content-Length'), 10)
|
||||
if (contentLength > Settings.maxUploadSize) {
|
||||
abortController.abort()
|
||||
stream.destroy()
|
||||
@@ -77,8 +71,35 @@ async function convertDocxToLaTeXZipArchive(path, userId) {
|
||||
return outputPath
|
||||
}
|
||||
|
||||
async function convertProjectToDocument(projectId, userId, type) {
|
||||
const limits = await CompileManager.promises._getUserCompileLimits(userId)
|
||||
const clsiRequest =
|
||||
await ClsiManager.promises.buildDocumentConversionRequest(projectId)
|
||||
|
||||
const clsiUrl = new URL(Settings.apis.clsi.url)
|
||||
clsiUrl.pathname = `/project/${projectId}/user/${userId}/download/project-to-document`
|
||||
clsiUrl.searchParams.set('type', type)
|
||||
clsiUrl.searchParams.set('compileBackendClass', limits.compileBackendClass)
|
||||
clsiUrl.searchParams.set('compileGroup', limits.compileGroup)
|
||||
|
||||
logger.debug(
|
||||
{ clsiUrl: clsiUrl.toString(), projectId, userId, type },
|
||||
'sending project to CLSI for document conversion'
|
||||
)
|
||||
|
||||
const { stream, response } = await fetchStreamWithResponse(clsiUrl, {
|
||||
method: 'POST',
|
||||
json: clsiRequest,
|
||||
})
|
||||
|
||||
const contentLength = parseInt(response.headers.get('Content-Length'), 10)
|
||||
|
||||
return { stream, contentLength }
|
||||
}
|
||||
|
||||
export default {
|
||||
promises: {
|
||||
convertDocxToLaTeXZipArchive,
|
||||
convertProjectToDocument,
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user