Files
Verso/services/web/app/src/Features/Downloads/ProjectDownloadsController.mjs
T
Davinder Singh be5a7b56c8 [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
2026-04-24 08:06:10 +00:00

129 lines
3.9 KiB
JavaScript

import Metrics from '@overleaf/metrics'
import ProjectGetter from '../Project/ProjectGetter.mjs'
import ProjectZipStreamManager from './ProjectZipStreamManager.mjs'
import DocumentUpdaterHandler from '../DocumentUpdater/DocumentUpdaterHandler.mjs'
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
Metrics.inc('zip-downloads')
DocumentUpdaterHandler.flushProjectToMongo(projectId, function (error) {
if (error) {
return next(error)
}
ProjectGetter.getProject(
projectId,
{ name: true },
function (error, project) {
if (error) {
return next(error)
}
ProjectAuditLogHandler.addEntryInBackground(
projectId,
'project-downloaded',
userId,
req.ip
)
ProjectZipStreamManager.createZipStreamForProject(
projectId,
function (error, stream) {
if (error) {
return next(error)
}
prepareZipAttachment(res, `${getSafeProjectName(project)}.zip`)
stream.pipe(res)
}
)
}
)
})
},
downloadMultipleProjects(req, res, next) {
const userId = SessionManager.getLoggedInUserId(req.session)
const projectIds = req.query.project_ids.split(',')
Metrics.inc('zip-downloads-multiple')
DocumentUpdaterHandler.flushMultipleProjectsToMongo(
projectIds,
function (error) {
if (error) {
return next(error)
}
// Log audit entry for each project in the batch
for (const projectId of projectIds) {
ProjectAuditLogHandler.addEntryInBackground(
projectId,
'project-downloaded',
userId,
req.ip
)
}
ProjectZipStreamManager.createZipStreamForMultipleProjects(
projectIds,
function (error, stream) {
if (error) {
return next(error)
}
prepareZipAttachment(
res,
`Overleaf Projects (${projectIds.length} items).zip`
)
stream.pipe(res)
}
)
}
)
},
}