[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:
Davinder Singh
2026-04-24 08:06:10 +00:00
committed by Copybot
parent b6ec7945f4
commit be5a7b56c8
20 changed files with 772 additions and 41 deletions
@@ -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,
},
}